run-highlighter/src/app.rs

938 lines
34 KiB
Rust
Executable File

use std::{
cell::{Cell, RefCell},
collections::HashMap,
io::Cursor,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{Receiver, SyncSender},
},
};
use std::collections::HashSet;
use std::sync::atomic::AtomicUsize;
use anyhow::Result;
use chrono::{DateTime, Duration, FixedOffset, Local, TimeZone, Utc};
use eframe::{
egui, egui::{CtxRef, Rgba, Sense, Widget},
epi,
epi::{Frame, RepaintSignal, Storage},
};
use eframe::egui::Ui;
use itertools::Itertools;
use livesplit::{
model::Attempt,
Run,
};
use livesplit::model::Segment;
use nom::Finish;
use serde::Deserialize;
use url::Url;
use crate::splits_logic::{SegmentLogic, SplitsLogic};
#[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>,
selected_tab: AtomicUsize,
picking_splits: bool,
processing_splits: bool,
splits_file_name: Option<String>,
splits: Option<(Run, RunStats)>,
processing_videos: AtomicBool,
videos: Option<Vec<VideosResponseVideo>>,
highlight_urls: RefCell<HashMap<u32, Url>>,
no_video: RefCell<HashSet<u32>>,
attempt_to_show: Cell<Option<u32>>,
channel: (SyncSender<Message>, Receiver<Message>),
}
impl Default for State {
fn default() -> Self {
Self {
last_error: Default::default(),
selected_tab: 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(),
no_video: Default::default(),
attempt_to_show: Default::default(),
channel: std::sync::mpsc::sync_channel(100),
}
}
}
enum Message {
Run(Result<(Run, RunStats)>),
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, stats)) => {
self.state.splits = Some((run, stats));
self.state.last_error = None;
self.state.highlight_urls.borrow_mut().clear();
self.state.no_video.borrow_mut().clear();
self.state.attempt_to_show.set(None);
// TODO: clear comparisons
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) {
if !self.create_highlight_link(attempt, &*videos) {
self.state.no_video.borrow_mut().insert(attempt.id);
}
}
}
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();
self.spawn(async move {
let res: Result<(Run, RunStats)> = try {
let bytes = Cursor::new(data);
let run: Run = quick_xml::de::from_reader(bytes)?;
let stats = RunStats::from(&run);
(run, stats)
};
sender.send(Message::Run(res)).ok();
repaint_signal.request_repaint();
});
}
}
}
}
egui::SidePanel::left("config").show(ctx, |ui| {
ui.heading("Config");
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.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("Highlighter", |ui| {
ui.checkbox(&mut self.config.highlighter.show_incomplete, "Show incomplete runs");
ui.label("Start padding");
ui.add(egui::Slider::new(&mut self.config.padding.start, 0..=30));
ui.label("End padding");
ui.add(egui::Slider::new(&mut self.config.padding.end, 0..=30));
})
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
if 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();
self.spawn(Self::choose_splits(sender, repaint_signal));
}
if let Some(name) = &self.state.splits_file_name {
ui.label(name);
}
});
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, stats)) = &self.state.splits {
ui.separator();
ui.heading(&run.game_name);
ui.label(&run.category_name);
ui.add_space(ui.spacing().item_spacing.y);
let mut items: Vec<(&str, Box<dyn FnMut(&mut Ui)>)> = vec![
("Runs", Box::new(|ui: &mut Ui| self.show_attempts(ui, ctx, frame, run))),
("Average", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.means, &stats.sub_means))),
("Median", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.medians, &stats.sub_medians))),
];
if self.state.attempt_to_show.get().is_some() {
items.insert(1, ("Run", Box::new(|ui: &mut Ui| self.show_attempt(ui, run))));
}
ui.horizontal_wrapped(|ui| {
for i in 0..items.len() {
let name = items[i].0;
if ui.selectable_label(self.state.selected_tab.load(Ordering::SeqCst) == i, name).clicked() {
self.state.selected_tab.store(i, Ordering::SeqCst);
}
}
});
ui.separator();
egui::ScrollArea::auto_sized().show(ui, |ui| {
let func = &mut items[self.state.selected_tab.load(Ordering::SeqCst)].1;
func(ui);
});
}
});
}
#[cfg(feature = "persistence")]
fn setup(&mut self, _ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>, storage: Option<&dyn epi::Storage>) {
if let Some(storage) = storage {
let loaded: Self = epi::get_value(storage, epi::APP_KEY).unwrap_or_default();
self.config = loaded.config;
}
}
#[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]) -> bool {
self.state.highlight_urls.borrow_mut().remove(&attempt.id);
let channel = match &self.config.twitch.channel {
Some(c) => c,
None => return false,
};
let splits = match &self.state.splits {
Some((s, _)) => s,
None => return false,
};
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 false,
};
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 false,
};
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);
true
}
fn show_attempts<'a>(&self, ui: &mut Ui, ctx: &CtxRef, frame: &mut Frame<'a>, run: &Run) {
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.highlighter.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| {
ui.columns(2, |ui| {
ui[0].label("Date");
ui[1].label("Duration");
});
ui.separator();
for attempt in attempts {
ui.columns(2, |ui| {
let channel = self.config.twitch.channel.clone();
ui[0].collapsing(Local.from_utc_datetime(&attempt.started).format("%H:%M:%S"), |ui| {
if ui.button("Use run").clicked() {
self.state.attempt_to_show.set(Some(attempt.id));
}
if let Some(id) = self.state.attempt_to_show.get() {
if id != attempt.id {
if ui.button("Compare").clicked() {
// self.state.attempt_to_compare.set(Some(attempt.id));
}
}
}
if ui.button("Attempt to create highlighter link").clicked() && !self.state.processing_videos.load(Ordering::SeqCst) {
match &self.state.videos {
Some(videos) => {
if !self.create_highlight_link(attempt, &**videos) {
self.state.no_video.borrow_mut().insert(attempt.id);
}
ctx.request_repaint();
}
None => {
self.state.processing_videos.store(true, Ordering::SeqCst);
let id = attempt.id;
let sender = self.state.channel.0.clone();
let repaint_signal = frame.repaint_signal();
let client_id = self.config.client.id.clone();
let client_secret = self.config.client.secret.clone();
self.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.hyperlink_to("Create highlight", url);
}
if self.state.no_video.borrow().contains(&attempt.id) {
egui::Label::new("Could not find a VOD for this run")
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
.ui(ui);
}
});
let mut output = vec![];
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))))
}
ui[1].label(output.join(" / "));
});
}
});
}
}
fn show_attempt(&self, ui: &mut Ui, run: &Run) {
let attempt_id = match self.state.attempt_to_show.get() {
Some(id) => id,
None => return,
};
let attempt = match run.attempt_history.attempts.iter().find(|attempt| attempt.id == attempt_id) {
Some(attempt) => attempt,
None => return,
};
let mut running = Duration::seconds(0);
let mut show_segment = |ui: &mut Ui, segment: &Segment| {
ui.columns(3, |ui| {
ui[0].label(segment.clean_name());
if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) {
if let Some(dur) = time.real_time.or(time.game_time) {
running = running + dur;
ui[1].label(to_human(&dur));
ui[2].label(to_human(&running));
}
}
});
};
ui.columns(3, |ui| {
ui[0].label("Split");
ui[1].label("Duration");
ui[2].label("Finished");
});
ui.separator();
egui::ScrollArea::auto_sized().show(ui, |ui| {
match run.subsplits() {
Some(subs) => {
let mut sub_finish = Duration::seconds(0);
for sub in subs {
ui.columns(3, |ui| {
let mut sub_time = Duration::seconds(0);
for segment in &sub.splits {
if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) {
if let Some(dur) = time.real_time.or(time.game_time) {
sub_time = sub_time + dur;
sub_finish = sub_finish + dur;
}
}
}
egui::Label::new(sub.name)
.strong()
.ui(&mut ui[0]);
egui::Label::new(to_human(&sub_time))
.strong()
.ui(&mut ui[1]);
egui::Label::new(to_human(&sub_finish))
.strong()
.ui(&mut ui[2]);
});
// ui.heading(sub.name);
for segment in &sub.splits {
show_segment(ui, segment);
}
}
}
None => {
for segment in &run.segments.segments {
show_segment(ui, segment);
}
}
}
});
}
fn show_simple_stats(&self, ui: &mut Ui, run: &Run, stats: &HashMap<String, f64>, sub_stats: &HashMap<String, f64>) {
let mut running = Duration::seconds(0);
let mut show_segment = |ui: &mut Ui, segment: &Segment| {
ui.columns(3, |ui| {
ui[0].label(segment.clean_name());
if let Some(&median) = stats.get(&segment.name) {
let dur = Duration::nanoseconds((median * 1_000_000.0) as i64);
running = running + dur;
ui[1].label(to_human(&dur));
ui[2].label(to_human(&running));
}
});
};
ui.columns(3, |ui| {
ui[0].label("Split");
ui[1].label("Duration");
ui[2].label("Finished");
});
ui.separator();
egui::ScrollArea::auto_sized().show(ui, |ui| {
if let Some(subsplits) = run.subsplits() {
for sub in subsplits {
ui.columns(3, |ui| {
egui::Label::new(sub.name)
.strong()
.ui(&mut ui[0]);
if let Some(sub_stat) = sub_stats.get(sub.name) {
let dur = Duration::nanoseconds((*sub_stat * 1_000_000.0) as i64);
egui::Label::new(to_human(&dur))
.strong()
.ui(&mut ui[1]);
}
});
for segment in sub.splits {
show_segment(ui, segment);
}
}
} else {
for segment in &run.segments.segments {
show_segment(ui, segment);
}
}
});
ui.separator();
ui.columns(3, |ui| {
ui[0].label("Total");
let sum: f64 = stats.values().sum();
let dur = Duration::nanoseconds((sum * 1_000_000.0) as i64);
ui[1].label(to_human(&dur));
});
}
async fn choose_splits(sender: SyncSender<Message>, repaint_signal: Arc<dyn RepaintSignal>) {
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();
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn<F>(&self, fut: F) where F: std::future::Future<Output=()> + Send + 'static {
tokio::task::spawn(fut);
}
#[cfg(target_arch = "wasm32")]
fn spawn<F>(&self, fut: F) where F: std::future::Future<Output=()> + 'static {
use futures::FutureExt;
let _ = wasm_bindgen_futures::future_to_promise(fut.then(|()| async { Ok(wasm_bindgen::JsValue::undefined()) }));
}
#[cfg(not(target_arch = "wasm32"))]
async fn send_request<S, D>(method: &str, url: S, headers: &[(String, String)]) -> Result<D>
where S: AsRef<str> + Send + Sync,
D: serde::de::DeserializeOwned + Send + 'static,
{
let method = method.to_string();
let url = url.as_ref().to_string();
let headers = headers.to_vec();
tokio::task::spawn_blocking(move || {
let mut req = ureq::request(&method, &url);
for (name, value) in headers {
req = req.set(&name, &value);
}
let d: D = req.call()?.into_json()?;
Ok(d)
}).await.unwrap()
}
#[cfg(target_arch = "wasm32")]
async fn send_request<S, D>(method: &str, url: S, headers: &[(String, String)]) -> Result<D>
where S: AsRef<str>,
D: serde::de::DeserializeOwned,
{
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let mut opts = web_sys::RequestInit::new();
opts.method(method);
opts.mode(web_sys::RequestMode::Cors);
let request = web_sys::Request::new_with_str_and_init(url.as_ref(), &opts)
.map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
request.headers().set("Accept", "*/*").map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
for (name, value) in headers {
request.headers().set(name, value).map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
}
let window = web_sys::window().unwrap();
let response = JsFuture::from(window.fetch_with_request(&request)).await
.map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
assert!(response.is_instance_of::<web_sys::Response>());
let response: web_sys::Response = response.dyn_into().unwrap();
let array_buffer = response.array_buffer()
.map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
let array_buffer = JsFuture::from(array_buffer).await
.map_err(|e| anyhow::anyhow!("couldn't make request: {:?}", e))?;
let uint8_array = js_sys::Uint8Array::new(&array_buffer);
let bytes = uint8_array.to_vec();
let d: D = serde_json::from_slice(&bytes)?;
Ok(d)
}
}
struct RunStats {
means: HashMap<String, f64>,
sub_means: HashMap<String, f64>,
medians: HashMap<String, f64>,
sub_medians: HashMap<String, f64>,
}
impl RunStats {
fn from(run: &Run) -> Self {
let mut means = HashMap::with_capacity(run.segments.segments.len());
let mut sub_means = HashMap::with_capacity(run.segments.segments.len());
let mut medians = HashMap::with_capacity(run.segments.segments.len());
let mut sub_medians = HashMap::with_capacity(run.segments.segments.len());
let get_median = |times: &[i64]| -> f64 {
if times.len() % 2 == 1 {
times[times.len() / 2] as f64
} else if times.len() > 1 {
let idx = times.len() / 2;
(times[idx] + times[idx - 1]) as f64 / 2.0
} else {
0.0
}
};
let do_stats = |segment: &Segment| -> (f64, f64) {
let times: Vec<i64> = segment.segment_history.times
.iter()
.filter_map(|time| time.real_time.or(time.game_time))
.map(|dur| dur.num_milliseconds())
.sorted_unstable()
.collect();
let median = get_median(&times);
let mean = times.iter().sum::<i64>() as f64 / times.len() as f64;
(median, mean)
};
match run.subsplits() {
Some(subs) => {
for sub in subs {
// for each split in the category
// collect their time in each attempt
// sum them together to get a category time
// collect all category times
// calc stats
let mut attempt_times = Vec::with_capacity(run.attempt_history.attempts.len());
for attempt in &run.attempt_history.attempts {
if attempt.real_time.or(attempt.game_time).is_none() {
continue;
}
let category_time = run.segments.segments
.iter()
// only consider segments in this category
.filter(|seg| sub.splits.iter().any(|segment| segment.name == seg.name))
.filter_map(|seg| seg.segment_history.times
.iter()
// find the time for this attempt
.find(|time| time.id == attempt.id)
.and_then(|time| time.real_time.or(time.game_time))
.map(|dur| dur.num_milliseconds())
)
.sum::<i64>();
attempt_times.push(category_time);
}
let sub_median = get_median(&attempt_times);
let sub_mean = attempt_times.iter().sum::<i64>() as f64 / attempt_times.len() as f64;
sub_medians.insert(sub.name.to_string(), sub_median);
sub_means.insert(sub.name.to_string(), sub_mean);
for segment in &sub.splits {
let (median, mean) = do_stats(segment);
medians.insert(segment.name.clone(), median);
means.insert(segment.name.clone(), mean);
}
}
}
None => {
for segment in &run.segments.segments {
let (median, mean) = do_stats(segment);
medians.insert(segment.name.clone(), median);
means.insert(segment.name.clone(), mean);
}
}
}
Self {
means,
sub_means,
medians,
sub_medians,
}
}
}
#[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 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 = App::send_request("POST", token_url, &[]).await?;
let headers = &[
("Client-ID".into(), client_id),
("Authorization".into(), format!("Bearer {}", creds.access_token)),
][..];
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 = App::send_request("GET", user_url, &headers).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 = App::send_request("GET", vids_url, &headers).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 millis = duration.num_milliseconds();
let mut secs = millis / 1000;
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)
}
}