chore: initial cli version
This commit is contained in:
commit
71ed4da07e
|
@ -0,0 +1 @@
|
||||||
|
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "run-highlighter"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = "0.4"
|
||||||
|
livesplit = { path = "../livesplit-rs" }
|
||||||
|
quick-xml = { version = "0.21", features = [ "serialize" ] }
|
||||||
|
twitch_oauth2 = "0.6.0-rc.2"
|
||||||
|
twitch_api2 = {version = "0.6.0-rc.2", features = ["client", "helix", "reqwest_client", "chrono"] }
|
||||||
|
reqwest = "0.11"
|
||||||
|
nom = "6"
|
||||||
|
url = "2"
|
||||||
|
toml = "0.5"
|
||||||
|
|
||||||
|
[dependencies.serde]
|
||||||
|
version = "1"
|
||||||
|
features = ["derive"]
|
||||||
|
|
||||||
|
[dependencies.tokio]
|
||||||
|
version = "1"
|
||||||
|
features = ["macros", "rt-multi-thread", "fs"]
|
|
@ -0,0 +1,14 @@
|
||||||
|
# The Client ID and secret for the application you created at
|
||||||
|
# https://dev.twitch.tv/
|
||||||
|
[client]
|
||||||
|
id = ""
|
||||||
|
secret = ""
|
||||||
|
|
||||||
|
[twitch]
|
||||||
|
# uncomment this with your username to always use this channel for highlights
|
||||||
|
# channel = ""
|
||||||
|
|
||||||
|
# The number of seconds to pad the highlight's start and end
|
||||||
|
[padding]
|
||||||
|
start = 10
|
||||||
|
end = 10
|
|
@ -0,0 +1,10 @@
|
||||||
|
[client]
|
||||||
|
id = "tgvi27f0ugkbxejfrn68oi5wrdrm7p"
|
||||||
|
secret = "06jhnnuqs774xe9z5va5a6tkecr3v1"
|
||||||
|
|
||||||
|
[twitch]
|
||||||
|
channel = "ascclemens"
|
||||||
|
|
||||||
|
[padding]
|
||||||
|
start = 10
|
||||||
|
end = 10
|
|
@ -0,0 +1,26 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub client: Client,
|
||||||
|
pub twitch: Twitch,
|
||||||
|
pub padding: Padding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Client {
|
||||||
|
pub id: String,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Twitch {
|
||||||
|
#[serde(default)]
|
||||||
|
pub channel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Padding {
|
||||||
|
pub start: i64,
|
||||||
|
pub end: i64,
|
||||||
|
}
|
|
@ -0,0 +1,230 @@
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{Duration, Local, Utc, TimeZone};
|
||||||
|
use twitch_api2::{
|
||||||
|
TwitchClient,
|
||||||
|
types::Nickname,
|
||||||
|
helix::{
|
||||||
|
users::{GetUsersRequest, User},
|
||||||
|
videos::{GetVideosRequest, Video},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use twitch_oauth2::{AppAccessToken, ClientId, ClientSecret, Scope};
|
||||||
|
use std::io::{BufReader, Write};
|
||||||
|
use std::fs::File;
|
||||||
|
use livesplit::Run;
|
||||||
|
use livesplit::model::Attempt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use nom::Finish;
|
||||||
|
use twitch_api2::types::VideoType;
|
||||||
|
use url::Url;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let res = inner().await;
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inner() -> Result<()> {
|
||||||
|
let path: std::path::PathBuf = std::env::current_exe()
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
.and_then(|p| p.parent().ok_or_else(|| anyhow::anyhow!("exe has no parent dir?")).map(ToOwned::to_owned))?;
|
||||||
|
let config_path = path.join("config.toml");
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
anyhow::bail!("no config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config_text = String::new();
|
||||||
|
tokio::fs::File::open(&config_path).await?.read_to_string(&mut config_text).await?;
|
||||||
|
let config: crate::config::Config = toml::from_str(&config_text)?;
|
||||||
|
|
||||||
|
let splits_path = match std::env::args().skip(1).next() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => anyhow::bail!("provide a path to your splits as the first argument"),
|
||||||
|
};
|
||||||
|
let file = BufReader::new(File::open(splits_path)?);
|
||||||
|
let splits: Run = quick_xml::de::from_reader(file)?;
|
||||||
|
|
||||||
|
let completed: Vec<&Attempt> = splits.attempt_history.attempts.iter()
|
||||||
|
.filter(|attempt| attempt.real_time.or(attempt.game_time).is_some())
|
||||||
|
.collect();
|
||||||
|
for i in 0..completed.len() {
|
||||||
|
let attempt = &*completed[i];
|
||||||
|
let mut output = vec![format!("{}. {}", completed.len() - i, Local.from_utc_datetime(&attempt.started))];
|
||||||
|
if let Some(real) = attempt.real_time {
|
||||||
|
output.push(format!("RTA: {}", to_string(&real)));
|
||||||
|
}
|
||||||
|
if let Some(igt) = attempt.game_time {
|
||||||
|
output.push(format!("IGT: {}", to_string(&igt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", output.join(" / "));
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection = loop {
|
||||||
|
print!("Choose a run to highlight > ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
match usize::from_str(s.trim()) {
|
||||||
|
Ok(i) if i > 0 && i < completed.len() + 1 => break i,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel = match config.twitch.channel {
|
||||||
|
Some(channel) => channel,
|
||||||
|
None => loop {
|
||||||
|
print!("Enter Twitch channel name > ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let name = s.trim();
|
||||||
|
if !name.is_empty() {
|
||||||
|
break name.to_string();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let run = &*completed[completed.len() - selection];
|
||||||
|
|
||||||
|
let client: TwitchClient<reqwest::Client> = TwitchClient::default();
|
||||||
|
let token = AppAccessToken::get_app_access_token(
|
||||||
|
&client,
|
||||||
|
ClientId::new(config.client.id),
|
||||||
|
ClientSecret::new(config.client.secret),
|
||||||
|
Scope::all(),
|
||||||
|
).await?;
|
||||||
|
let req = GetUsersRequest::builder()
|
||||||
|
.login(vec![Nickname::new(&channel)])
|
||||||
|
.build();
|
||||||
|
let res: Vec<User> = client.helix.req_get(req, &token).await?.data;
|
||||||
|
let user_id = &res[0].id;
|
||||||
|
|
||||||
|
let req = GetVideosRequest::builder()
|
||||||
|
.user_id(user_id.clone())
|
||||||
|
.build();
|
||||||
|
let videos: Vec<Video> = client.helix.req_get(req, &token).await?.data;
|
||||||
|
|
||||||
|
let video = videos
|
||||||
|
.iter()
|
||||||
|
.filter(|video| video.type_ == VideoType::Archive)
|
||||||
|
.filter_map(|video| twitch_duration_parse(&video.duration).map(|duration| (video, duration)))
|
||||||
|
.find(|(video, duration)| {
|
||||||
|
let ended_at = video.created_at.to_utc() + *duration;
|
||||||
|
video.created_at.to_utc() <= Utc.from_utc_datetime(&run.started) && ended_at >= Utc.from_utc_datetime(&run.ended)
|
||||||
|
});
|
||||||
|
|
||||||
|
let (video, duration) = match video {
|
||||||
|
Some(v) => v,
|
||||||
|
None => anyhow::bail!("could not find a vod for this run"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = (Utc.from_utc_datetime(&run.started) - video.created_at.to_utc()).num_seconds();
|
||||||
|
let end = (Utc.from_utc_datetime(&run.ended) - video.created_at.to_utc()).num_seconds();
|
||||||
|
|
||||||
|
let start = (start - config.padding.start).max(0);
|
||||||
|
let end = (end + config.padding.end).min(duration.num_seconds());
|
||||||
|
|
||||||
|
let mut url = Url::parse("https://dashboard.twitch.tv/").unwrap()
|
||||||
|
.join("u/")?
|
||||||
|
.join(&format!("{}/", channel))?
|
||||||
|
.join("content/")?
|
||||||
|
.join("video-producer/")?
|
||||||
|
.join("highlighter/")?
|
||||||
|
.join(&video.id.to_string())?;
|
||||||
|
let run_time = run.game_time.or(run.real_time).unwrap();
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("start", &start.to_string())
|
||||||
|
.append_pair("end", &end.to_string())
|
||||||
|
.append_pair("title", &format!("{} PB in {}", splits.game_name, to_string(&run_time)));
|
||||||
|
println!("\nGo to the URL below to review the highlight and publish it.\n{}\n\nPress enter to finish.", url);
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_string(duration: &Duration) -> String {
|
||||||
|
let mut nanos = duration.num_nanoseconds().unwrap();
|
||||||
|
|
||||||
|
let mut secs = nanos / 1000000000;
|
||||||
|
nanos %= 1000000000;
|
||||||
|
|
||||||
|
let mut mins = secs / 60;
|
||||||
|
secs %= 60;
|
||||||
|
|
||||||
|
let hours = mins / 60;
|
||||||
|
mins %= 60;
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{:0>2}:{:0>2}:{:0>2}.{:0>9}",
|
||||||
|
hours,
|
||||||
|
mins,
|
||||||
|
secs,
|
||||||
|
nanos,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn twitch_duration_parse(input: &str) -> Option<Duration> {
|
||||||
|
use nom::{
|
||||||
|
bytes::complete::{tag, take_while},
|
||||||
|
combinator::map_res,
|
||||||
|
sequence::tuple,
|
||||||
|
IResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn from_num(input: &str) -> Result<u32, std::num::ParseIntError> {
|
||||||
|
u32::from_str_radix(input, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_digit(c: char) -> bool {
|
||||||
|
c.is_digit(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(input: &str) -> IResult<&str, u32> {
|
||||||
|
map_res(
|
||||||
|
take_while(is_digit),
|
||||||
|
from_num,
|
||||||
|
)(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, (opt_hours, opt_mins, opt_secs)) = tuple((
|
||||||
|
nom::combinator::opt(tuple((
|
||||||
|
number,
|
||||||
|
tag("h"),
|
||||||
|
))),
|
||||||
|
nom::combinator::opt(tuple((
|
||||||
|
number,
|
||||||
|
tag("m"),
|
||||||
|
))),
|
||||||
|
nom::combinator::opt(tuple((
|
||||||
|
number,
|
||||||
|
tag("s"),
|
||||||
|
))),
|
||||||
|
))(input).finish().ok()?;
|
||||||
|
|
||||||
|
let mut result = Duration::seconds(0);
|
||||||
|
if let Some((hours, _)) = opt_hours {
|
||||||
|
result = result + Duration::hours(i64::from(hours));
|
||||||
|
}
|
||||||
|
if let Some((mins, _)) = opt_mins {
|
||||||
|
result = result + Duration::minutes(i64::from(mins));
|
||||||
|
}
|
||||||
|
if let Some((secs, _)) = opt_secs {
|
||||||
|
result = result + Duration::seconds(i64::from(secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == Duration::seconds(0) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue