feat: add gui
This commit is contained in:
parent
71ed4da07e
commit
acfae81284
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
|
@ -3,19 +3,29 @@ name = "run-highlighter"
|
|||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "runhighlighter"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = "0.4"
|
||||
eframe = "0.13"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
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"
|
||||
futures = "0.3"
|
||||
itertools = "0.10"
|
||||
rfd = "0.4"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.11"
|
||||
default-features = false
|
||||
features = ["json", "rustls-tls-webpki-roots"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
|
@ -23,4 +33,10 @@ features = ["derive"]
|
|||
|
||||
[dependencies.tokio]
|
||||
version = "1"
|
||||
features = ["macros", "rt-multi-thread", "fs"]
|
||||
default-features = false
|
||||
features = ["rt-multi-thread"]
|
||||
|
||||
[features]
|
||||
default = ["persistence"]
|
||||
http = ["eframe/http"] # Enable if you want to do http requests
|
||||
persistence = ["eframe/persistence"] # Enable if you want to persist app state on shutdown
|
||||
|
|
|
@ -0,0 +1,567 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{Duration, Local, Utc, TimeZone, FixedOffset, DateTime};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{SyncSender, Receiver},
|
||||
},
|
||||
};
|
||||
use livesplit::{
|
||||
Run,
|
||||
model::Attempt,
|
||||
};
|
||||
use nom::Finish;
|
||||
use url::Url;
|
||||
use eframe::{
|
||||
egui, epi,
|
||||
egui::{CtxRef, Sense, Rgba, Widget},
|
||||
epi::{Frame, Storage},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use itertools::Itertools;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[cfg_attr(feature = "persistence", serde(default))] // if we add new fields, give them default values when deserializing old state
|
||||
pub struct App {
|
||||
config: crate::config::Config,
|
||||
|
||||
#[cfg_attr(feature = "persistence", serde(skip))]
|
||||
state: State,
|
||||
}
|
||||
|
||||
struct State {
|
||||
last_error: Option<anyhow::Error>,
|
||||
|
||||
picking_splits: bool,
|
||||
processing_splits: bool,
|
||||
splits_file_name: Option<String>,
|
||||
splits: Option<Run>,
|
||||
|
||||
processing_videos: AtomicBool,
|
||||
videos: Option<Vec<VideosResponseVideo>>,
|
||||
highlight_urls: RefCell<HashMap<u32, Url>>,
|
||||
|
||||
channel: (SyncSender<Message>, Receiver<Message>),
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_error: Default::default(),
|
||||
|
||||
picking_splits: Default::default(),
|
||||
processing_splits: Default::default(),
|
||||
splits_file_name: Default::default(),
|
||||
splits: Default::default(),
|
||||
|
||||
processing_videos: Default::default(),
|
||||
videos: Default::default(),
|
||||
highlight_urls: Default::default(),
|
||||
|
||||
channel: std::sync::mpsc::sync_channel(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Run(Result<Run>),
|
||||
Videos(u32, Result<Vec<VideosResponseVideo>>),
|
||||
SplitsPath(Option<(String, Vec<u8>)>),
|
||||
}
|
||||
|
||||
impl epi::App for App {
|
||||
fn update(&mut self, ctx: &CtxRef, frame: &mut Frame<'_>) {
|
||||
if let Ok(message) = self.state.channel.1.try_recv() {
|
||||
match message {
|
||||
Message::Run(run) => {
|
||||
self.state.processing_splits = false;
|
||||
|
||||
match run {
|
||||
Ok(run) => {
|
||||
self.state.splits = Some(run);
|
||||
self.state.last_error = None;
|
||||
|
||||
self.state.highlight_urls.borrow_mut().clear();
|
||||
self.state.videos = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.splits = None;
|
||||
self.state.last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Videos(id, videos) => {
|
||||
self.state.processing_videos.store(false, Ordering::SeqCst);
|
||||
|
||||
match videos {
|
||||
Ok(videos) => {
|
||||
if let Some(splits) = &self.state.splits {
|
||||
if let Some(attempt) = splits.attempt_history.attempts.iter().find(|attempt| attempt.id == id) {
|
||||
self.create_highlight_link(attempt, &*videos);
|
||||
}
|
||||
}
|
||||
|
||||
self.state.videos = Some(videos);
|
||||
self.state.last_error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.videos = None;
|
||||
self.state.last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::SplitsPath(handle) => {
|
||||
self.state.picking_splits = false;
|
||||
|
||||
if let Some((file_name, data)) = handle {
|
||||
self.state.splits_file_name = Some(file_name);
|
||||
self.state.processing_splits = true;
|
||||
|
||||
let repaint_signal = frame.repaint_signal();
|
||||
let sender = self.state.channel.0.clone();
|
||||
tokio::task::spawn(async move {
|
||||
let res: Result<Run> = try {
|
||||
let bytes = Cursor::new(data);
|
||||
let run: Run = quick_xml::de::from_reader(bytes)?;
|
||||
run
|
||||
};
|
||||
sender.send(Message::Run(res)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
egui::SidePanel::left("config").show(ctx, |ui| {
|
||||
ui.heading("Config");
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Client", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Client ID");
|
||||
let question_mark = egui::Label::new("❓")
|
||||
.sense(Sense::click())
|
||||
.ui(ui)
|
||||
.on_hover_ui(|ui| {
|
||||
ui.heading("How do I get a client ID and client secret?");
|
||||
ui.label("1. Click this question mark to go to Twitch's developer website.");
|
||||
ui.label("2. Click \"Log in with Twitch\"");
|
||||
ui.label("3. Click \"Register Your Application\"");
|
||||
ui.label("4. Give the application a unique, memorable name like \"<your username> auto-highlighter\"");
|
||||
ui.label("5. Enter http://localhost as the redirect URL and click \"Add\"");
|
||||
ui.label("6. Fill out the rest of the form and submit");
|
||||
ui.label("7. Click \"Manage\" on your new application");
|
||||
ui.label("8. Copy the client ID and then the client secret");
|
||||
});
|
||||
|
||||
if question_mark.clicked() {
|
||||
ui.ctx().output().open_url = Some(egui::output::OpenUrl {
|
||||
url: "https://dev.twitch.tv/".into(),
|
||||
new_tab: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.text_edit_singleline(&mut self.config.client.id);
|
||||
|
||||
ui.label("Client secret");
|
||||
egui::TextEdit::singleline(&mut self.config.client.secret).password(true).ui(ui);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Twitch", |ui| {
|
||||
ui.label("Channel");
|
||||
let mut s = self.config.twitch.channel.clone().unwrap_or_default();
|
||||
ui.text_edit_singleline(&mut s);
|
||||
let s = s.trim();
|
||||
self.config.twitch.channel = if s.is_empty() { None } else { Some(s.to_string()) };
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Padding", |ui| {
|
||||
ui.label("Start");
|
||||
ui.add(egui::Slider::new(&mut self.config.padding.start, 0..=30));
|
||||
|
||||
ui.label("End");
|
||||
ui.add(egui::Slider::new(&mut self.config.padding.end, 0..=30));
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Display", |ui| {
|
||||
ui.checkbox(&mut self.config.display.show_incomplete, "Show incomplete runs");
|
||||
})
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if !self.config.ready() || self.state.picking_splits {
|
||||
ui.set_enabled(false);
|
||||
}
|
||||
if ui.button("Open splits").clicked() && !self.state.picking_splits {
|
||||
self.state.picking_splits = true;
|
||||
|
||||
let repaint_signal = frame.repaint_signal();
|
||||
let sender = self.state.channel.0.clone();
|
||||
tokio::task::spawn(async move {
|
||||
let handle = rfd::AsyncFileDialog::default()
|
||||
.add_filter("LiveSplit splits", &["lss"])
|
||||
.pick_file()
|
||||
.await;
|
||||
let data = match handle {
|
||||
Some(handle) => {
|
||||
let name = handle.file_name();
|
||||
let data = handle.read().await;
|
||||
Some((name, data))
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
sender.send(Message::SplitsPath(data)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(name) = &self.state.splits_file_name {
|
||||
ui.label(name);
|
||||
}
|
||||
});
|
||||
|
||||
if !self.config.ready() {
|
||||
egui::Label::new("Fill out the configuration first.")
|
||||
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
|
||||
.ui(ui);
|
||||
}
|
||||
|
||||
if let Some(e) = &self.state.last_error {
|
||||
egui::Label::new(format!("Error: {}", e))
|
||||
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
|
||||
.ui(ui)
|
||||
.on_hover_ui(|ui| {
|
||||
egui::Label::new(format!("{:#?}", e))
|
||||
.monospace()
|
||||
.ui(ui);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(run) = &self.state.splits {
|
||||
ui.separator();
|
||||
|
||||
ui.heading(&run.game_name);
|
||||
ui.label(&run.category_name);
|
||||
ui.add_space(ui.spacing().item_spacing.y);
|
||||
|
||||
egui::ScrollArea::auto_sized().show(ui, |ui| {
|
||||
for (key, attempts) in &run.attempt_history.attempts.iter().rev().group_by(|attempt| Local.from_utc_datetime(&attempt.started).date()) {
|
||||
let attempts: Vec<&Attempt> = attempts
|
||||
.filter(|attempt| self.config.display.show_incomplete || attempt.real_time.or(attempt.game_time).is_some())
|
||||
.collect();
|
||||
if attempts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.collapsing(key.format("%d %B %Y"), |ui| {
|
||||
for attempt in attempts {
|
||||
let mut output = vec![Local.from_utc_datetime(&attempt.started).format("%H:%M:%S").to_string()];
|
||||
if let Some(real) = attempt.real_time {
|
||||
output.push(format!("RTA: {}", to_human(&real)));
|
||||
}
|
||||
if let Some(igt) = attempt.game_time {
|
||||
output.push(format!("IGT: {}", to_human(&igt)));
|
||||
}
|
||||
if attempt.real_time.or(attempt.game_time).is_none() {
|
||||
output.push(format!("Length: {}", to_human(&(attempt.ended - attempt.started))))
|
||||
}
|
||||
|
||||
let sender = self.state.channel.0.clone();
|
||||
let channel = self.config.twitch.channel.clone();
|
||||
let id = attempt.id;
|
||||
if ui.selectable_label(false, output.join(" / ")).clicked() && channel.is_some() && !self.state.processing_videos.load(Ordering::SeqCst) {
|
||||
match &self.state.videos {
|
||||
Some(videos) => {
|
||||
self.create_highlight_link(attempt, &**videos);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
None => {
|
||||
self.state.processing_videos.store(true, Ordering::SeqCst);
|
||||
|
||||
let repaint_signal = frame.repaint_signal();
|
||||
let client_id = self.config.client.id.clone();
|
||||
let client_secret = self.config.client.secret.clone();
|
||||
tokio::task::spawn(async move {
|
||||
let res = get_videos(&channel.unwrap(), client_id, client_secret).await;
|
||||
sender.send(Message::Videos(id, res)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = self.state.highlight_urls.borrow().get(&attempt.id) {
|
||||
ui.indent(format!("attempt-{}-links", attempt.id), |ui| {
|
||||
ui.hyperlink_to("Create highlight", url);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "persistence")]
|
||||
fn setup(&mut self, _ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>, storage: Option<&dyn epi::Storage>) {
|
||||
if let Some(storage) = storage {
|
||||
*self = epi::get_value(storage, epi::APP_KEY).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "persistence")]
|
||||
fn save(&mut self, storage: &mut dyn Storage) {
|
||||
epi::set_value(storage, epi::APP_KEY, self);
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Twitch Speedrun Highlighter"
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn create_highlight_link(&self, attempt: &Attempt, videos: &[VideosResponseVideo]) {
|
||||
let channel = match &self.config.twitch.channel {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let splits = match &self.state.splits {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let video = videos
|
||||
.iter()
|
||||
.filter(|video| video.kind == "archive" || video.kind == "Archive")
|
||||
.filter_map(|video| twitch_duration_parse(&video.duration).map(|duration| (video, duration)))
|
||||
.find(|(video, duration)| {
|
||||
let ended_at = video.created_at + *duration;
|
||||
video.created_at <= Utc.from_utc_datetime(&attempt.started) && ended_at >= Utc.from_utc_datetime(&attempt.ended)
|
||||
});
|
||||
|
||||
let (video, duration) = match video {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let start = (Utc.from_utc_datetime(&attempt.started) - video.created_at.with_timezone(&Utc)).num_seconds();
|
||||
let end = (Utc.from_utc_datetime(&attempt.ended) - video.created_at.with_timezone(&Utc)).num_seconds();
|
||||
|
||||
let start = (start - self.config.padding.start).max(0);
|
||||
let end = (end + self.config.padding.end).min(duration.num_seconds());
|
||||
|
||||
let url: Result<Url> = try {
|
||||
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 mut url = match url {
|
||||
Ok(u) => u,
|
||||
Err(_) => return,
|
||||
};
|
||||
let run_time = match attempt.game_time.or(attempt.real_time) {
|
||||
Some(t) => t,
|
||||
None => attempt.ended - attempt.started,
|
||||
};
|
||||
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)));
|
||||
self.state.highlight_urls.borrow_mut().insert(attempt.id, url);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AppCredentials {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsersResponse {
|
||||
data: Vec<UsersResponseUser>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsersResponseUser {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VideosResponse {
|
||||
data: Vec<VideosResponseVideo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VideosResponseVideo {
|
||||
id: String,
|
||||
created_at: DateTime<FixedOffset>,
|
||||
duration: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
}
|
||||
|
||||
async fn get_videos(channel: &str, client_id: String, client_secret: String) -> Result<Vec<VideosResponseVideo>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut token_url = Url::parse("https://id.twitch.tv/oauth2/token").unwrap();
|
||||
token_url.query_pairs_mut()
|
||||
.append_pair("client_id", &client_id)
|
||||
.append_pair("client_secret", &client_secret)
|
||||
.append_pair("grant_type", "client_credentials");
|
||||
let creds: AppCredentials = client.post(token_url)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let mut user_url = Url::parse("https://api.twitch.tv/helix/users").unwrap();
|
||||
user_url.query_pairs_mut()
|
||||
.append_pair("login", channel);
|
||||
let users: UsersResponse = client.get(user_url)
|
||||
.header("Client-ID", &client_id)
|
||||
.header("Authorization", format!("Bearer {}", creds.access_token))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if users.data.is_empty() {
|
||||
anyhow::bail!("no such twitch channel");
|
||||
}
|
||||
|
||||
let user = &users.data[0];
|
||||
|
||||
let mut vids_url = Url::parse("https://api.twitch.tv/helix/videos").unwrap();
|
||||
vids_url.query_pairs_mut()
|
||||
.append_pair("user_id", &user.id);
|
||||
let videos: VideosResponse = client.get(vids_url)
|
||||
.header("Client-ID", &client_id)
|
||||
.header("Authorization", format!("Bearer {}", creds.access_token))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(videos.data)
|
||||
}
|
||||
|
||||
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 to_human(duration: &Duration) -> String {
|
||||
let mut secs = duration.num_seconds();
|
||||
|
||||
let mut mins = secs / 60;
|
||||
secs %= 60;
|
||||
|
||||
let hours = mins / 60;
|
||||
mins %= 60;
|
||||
|
||||
let mut parts = Vec::with_capacity(4);
|
||||
if hours > 0 {
|
||||
parts.push(format!("{}h", hours));
|
||||
}
|
||||
|
||||
if mins > 0 || hours > 0 {
|
||||
parts.push(format!("{}m", mins));
|
||||
}
|
||||
|
||||
if secs > 0 || mins > 0 || hours > 0 {
|
||||
parts.push(format!("{}s", secs));
|
||||
}
|
||||
|
||||
parts.join("")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -1,26 +1,59 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[derive(Default)]
|
||||
pub struct Config {
|
||||
pub client: Client,
|
||||
pub twitch: Twitch,
|
||||
pub padding: Padding,
|
||||
#[serde(default)]
|
||||
pub display: Display,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
impl Config {
|
||||
pub fn ready(&self) -> bool {
|
||||
!self.client.id.is_empty()
|
||||
&& !self.client.secret.is_empty()
|
||||
&& self.twitch.channel.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[derive(Default)]
|
||||
pub struct Client {
|
||||
pub id: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[derive(Default)]
|
||||
pub struct Twitch {
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "persistence", serde(default))]
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct Padding {
|
||||
pub start: i64,
|
||||
pub end: i64,
|
||||
}
|
||||
|
||||
impl Default for Padding {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start: 10,
|
||||
end: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct Display {
|
||||
pub show_incomplete: bool,
|
||||
}
|
||||
|
||||
impl Default for Display {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_incomplete: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
#![feature(try_blocks, async_closure)]
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(not(debug_assertions), deny(warnings))] // Forbid warnings in release builds
|
||||
#![warn(clippy::all, rust_2018_idioms)]
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
pub use crate::app::App;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// When compiling for web:
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::wasm_bindgen::{self, prelude::*};
|
||||
|
||||
/// This is the entry-point for all the web-assembly.
|
||||
/// This is called once from the HTML.
|
||||
/// It loads the app, installs some callbacks, then returns.
|
||||
/// You can add more callbacks like this if you want to call in to your code.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: &str) -> Result<(), eframe::wasm_bindgen::JsValue> {
|
||||
let app = App::default();
|
||||
eframe::start_web(canvas_id, Box::new(app))
|
||||
}
|
242
src/main.rs
242
src/main.rs
|
@ -1,230 +1,20 @@
|
|||
#![feature(try_blocks, async_closure)]
|
||||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
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;
|
||||
use crate::app::App;
|
||||
|
||||
#[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)
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let app = App::default();
|
||||
let native_options = eframe::NativeOptions::default();
|
||||
eframe::run_native(Box::new(app), native_options);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue