diff options
| -rw-r--r-- | Cargo.lock | 14 | ||||
| -rw-r--r-- | Cargo.toml | 9 | ||||
| -rw-r--r-- | src/commands/meme.rs | 2 | ||||
| -rw-r--r-- | src/commands/playback/mod.rs | 243 | ||||
| -rw-r--r-- | src/commands/playback/types.rs | 121 | ||||
| -rw-r--r-- | src/main.rs | 5 |
6 files changed, 386 insertions, 8 deletions
@@ -1,3 +1,5 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. [[package]] name = "MacTypes-sys" version = "2.1.0" @@ -638,6 +640,14 @@ dependencies = [ ] [[package]] +name = "itertools" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "either 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "itoa" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1716,14 +1726,17 @@ dependencies = [ "either 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", "fern 0.5.7 (registry+https://github.com/rust-lang/crates.io-index)", + "itertools 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", "mime_guess 1.8.6 (registry+https://github.com/rust-lang/crates.io-index)", "rand 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.9 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.37 (registry+https://github.com/rust-lang/crates.io-index)", "serenity 0.5.11 (git+https://github.com/mammothbane/serenity)", "sha1 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)", "typemap 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)", "url 1.7.2 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -2130,6 +2143,7 @@ dependencies = [ "checksum idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" "checksum indexmap 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7e81a7c05f79578dbc15793d8b619db9ba32b4577003ef3af1a91c416798c58d" "checksum iovec 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "dbe6e417e7d0975db6512b90796e8ce223145ac4e33c377e4a42882a0e88bb08" +"checksum itertools 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5b8467d9c1cebe26feb08c640139247fac215782d35371ade9a2136ed6085358" "checksum itoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "1306f3464951f30e30d12373d31c79fbd52d236e5e896fd92f96ec7babbbe60b" "checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" "checksum language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" @@ -15,16 +15,19 @@ url = "1.7.2" dotenv = "0.13.0" dotenv_codegen = "0.11.0" chrono = "0.4.6" +time = "0.1.42" fern = { version = "0.5.7", features = ["colored"] } diesel = { version = "1.3.3", features = ["postgres", "chrono", "r2d2"], optional = true } ctrlc = { version = "3.1.1", features = ["termination"] } -rand = "0.6.1" -either = "1.5.0" -reqwest = "0.9.5" +rand = "~0.6" +either = "~1.5" +reqwest = "^0.9" sha1 = { version = "0.6.0", features = ["std"] } mime_guess = "1.8.6" regex = "1.1.0" clap = "2.32.0" +itertools = "0.8.0" +serde_json = "~1.0" [dependencies.serenity] default-features = false diff --git a/src/commands/meme.rs b/src/commands/meme.rs index 6d50349..8514eed 100644 --- a/src/commands/meme.rs +++ b/src/commands/meme.rs @@ -218,6 +218,8 @@ fn send_meme(ctx: &Context, t: &Meme, conn: &PgConnection, msg: &Message) -> Res initiator: msg.author.name.clone(), data: ::either::Right(audio.data.clone()), sender_channel: msg.channel_id, + start: None, + end: None, }); } diff --git a/src/commands/playback/mod.rs b/src/commands/playback/mod.rs index 051fabb..e5e7a4c 100644 --- a/src/commands/playback/mod.rs +++ b/src/commands/playback/mod.rs @@ -1,4 +1,7 @@ use either::{Left, Right}; +use regex::Regex; +use time::Duration; + use serenity::{ framework::standard::Args, model::{ @@ -46,29 +49,265 @@ impl CtxExt for Context { } pub fn _play(ctx: &Context, msg: &Message, url: &str) -> Result<()> { + use url::{Url, Host}; + debug!("playing '{}'", url); if !url.starts_with("http") { send(msg.channel_id, "bAD LiNk", msg.tts)?; return Ok(()); } - if url.contains("imgur") { + let url = match Url::parse(url) { + Err(e) => { + send(msg.channel_id, "INVALID URL", msg.tts)?; + return Err(e.into()); + }, + Ok(u) => u, + }; + + let host = url.host().and_then(|u| match u { + Host::Domain(h) => Some(h.to_owned()), + _ => None, + }); + + if host.map(|h| h.to_lowercase().contains("imgur")).unwrap_or(false) { send(msg.channel_id, "IMGUR IS BAD, YOU TRASH CAN MAN", msg.tts)?; return Ok(()); } + let (start, end) = parse_times(&msg.content); + let queue_lock = ctx.data.lock().get::<PlayQueue>().cloned().unwrap(); let mut play_queue = queue_lock.write().unwrap(); play_queue.queue.push_back(PlayArgs{ initiator: msg.author.name.clone(), - data: Left(url.to_owned()), + data: Left(url.into_string()), sender_channel: msg.channel_id, + start, + end, }); Ok(()) } +lazy_static! { + static ref START_REGEX: Regex = + Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap(); + + static ref DUR_REGEX: Regex = + Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap(); + + static ref END_REGEX: Regex = + Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P<hours>\d+)h\s?)?(?:(?P<minutes>\d+)m\s?)?(?:(?P<seconds>\d+)s?)?").unwrap(); +} + +fn parse_times<A: AsRef<str>>(s: A) -> (Option<Duration>, Option<Duration>) { + use regex::Match; + + fn parse_match(m: Option<Match>) -> u64 { + m.and_then(|s| s.as_str().parse::<u64>().ok()).unwrap_or(0) + } + + fn parse_captures<B: AsRef<str>>(r: &Regex, s: B) -> Option<Duration> { + r.captures(s.as_ref()) + .map(|capt| { + let hours = parse_match(capt.name("hours")); + let minutes = parse_match(capt.name("minutes")); + let seconds = parse_match(capt.name("seconds")); + + let result = Duration::hours(hours as i64) + + Duration::minutes(minutes as i64) + + Duration::seconds(seconds as i64); + + assert!(result >= Duration::zero()); + + result + }) + } + + let start_time = parse_captures(&START_REGEX, &s); + let dur = parse_captures(&DUR_REGEX, &s); + let end_time = parse_captures(&END_REGEX, s) + .or_else(|| start_time.and_then(|start| dur.map(|d| start + d))); + + (start_time, end_time) +} + +#[cfg(test)] +mod test { + use super::*; + use time::Duration; + + #[test] + fn test_start() { + let captures = START_REGEX.captures("start 1h2m3s").unwrap(); + + assert_eq!(captures.name("hours").unwrap().as_str(), "1"); + assert_eq!(captures.name("minutes").unwrap().as_str(), "2"); + assert_eq!(captures.name("seconds").unwrap().as_str(), "3"); + + assert!(START_REGEX.captures("").is_none()); + assert!(START_REGEX.captures("start s").is_none()); + + let captures = START_REGEX.captures("start 1").unwrap(); + assert_eq!(captures.name("seconds").unwrap().as_str(), "1"); + } + + #[test] + fn test_dur() { + let captures = DUR_REGEX.captures("dur 1h2m3s").unwrap(); + + assert_eq!(captures.name("hours").unwrap().as_str(), "1"); + assert_eq!(captures.name("minutes").unwrap().as_str(), "2"); + assert_eq!(captures.name("seconds").unwrap().as_str(), "3"); + + assert!(DUR_REGEX.captures("").is_none()); + assert!(DUR_REGEX.captures("dur s").is_none()); + + let captures = DUR_REGEX.captures("dur 1").unwrap(); + assert_eq!(captures.name("seconds").unwrap().as_str(), "1"); + } + + #[test] + fn test_end() { + let captures = END_REGEX.captures("end 1h2m3s").unwrap(); + + assert_eq!(captures.name("hours").unwrap().as_str(), "1"); + assert_eq!(captures.name("minutes").unwrap().as_str(), "2"); + assert_eq!(captures.name("seconds").unwrap().as_str(), "3"); + + assert!(END_REGEX.captures("").is_none()); + assert!(END_REGEX.captures("end s").is_none()); + + let captures = END_REGEX.captures("end 1").unwrap(); + assert_eq!(captures.name("seconds").unwrap().as_str(), "1"); + } + + #[test] + fn test_parse_matrix() { + fn format_time(d: &Duration) -> impl Iterator<Item=String> { + let seconds = d.num_seconds() % 60; + let minutes = d.num_minutes() % 60; + let hours = d.num_hours(); + + let elems = vec![true, false]; + + #[inline] + fn format_maybe_zero<S: AsRef<str>>(v: i64, unit: S, always: bool) -> String { + if always || v != 0 { + format!("{}{}", v, unit.as_ref()) + } else { + "".to_owned() + } + } + + iproduct!(elems.clone(), elems.clone(), elems) + .filter_map(move |(secs, mins, hr)| { + if !secs && !mins && !hr { + return None; + } + + let hr_string = format_maybe_zero(hours, "h", hr); + let mn_string = format_maybe_zero(minutes, "m", mins); + let sec_string = format_maybe_zero(seconds, "s", secs); + + Some(format!("{}{}{}", hr_string, mn_string, sec_string)) + }) + } + + #[inline] + fn produce_time_strings(d: Option<Duration>, names: Vec<&'static str>) -> Box<dyn Iterator<Item=String>> { + d + .map(move |dur| { + let iter = iproduct!(format_time(&dur), names.into_iter()) + .map(|(time, name)| format!("{} {}", name, time)); + Box::new(iter) as Box<dyn Iterator<Item=String>> + }) + .unwrap_or(Box::new(vec!["".to_owned()].into_iter()) as Box<dyn Iterator<Item=String>>) + } + + #[inline] + fn dur_strs(v: Vec<Option<Duration>>, names: Vec<&'static str>) -> Vec<String> { + v + .into_iter() + .flat_map(|d| produce_time_strings(d, names.clone())) + .collect() + } + + let start_times = vec![None, Some(Duration::seconds(0)), Some(Duration::seconds(32))]; + let durs = vec![None, Some(Duration::seconds(0)), Some(Duration::seconds(123141))]; + let end_times = vec![None, Some(Duration::seconds(0)), Some(Duration::seconds(19851598))]; + + let start_names = vec!["start", "begin", "beginning"]; + let dur_names = vec!["dur", "duration"]; + let end_names = vec!["end", "term", "terminate", "termination"]; + + let pairs = vec! [ + (start_times, start_names), + (durs, dur_names), + (end_times, end_names), + ]; + + let elems = pairs.into_iter() + .map(|(times, names)| { + let result = times.into_iter() + .flat_map(move |d| { + let names_iter = names.clone().into_iter(); + + d.as_ref().map(move |dur| { + let dur = dur.clone(); + + Box::new(iproduct!(format_time(&dur), names_iter) + .map(move |(time, name)| Some((dur, format!("{} {}", name, time))))) as Box<dyn Iterator<Item=Option<(Duration, String)>>> + }).unwrap_or_else(|| Box::new(::std::iter::once(None))) + }); + + result.collect::<Vec<Option<(Duration, String)>>>() + }) + .collect::<Vec<Vec<Option<(Duration, String)>>>>(); + + let start_iters = &elems[0]; + let dur_iters = &elems[1]; + let end_iters = &elems[2]; + + iproduct!(start_iters, dur_iters, end_iters) + .for_each(|(start, dur, end)| { + let s = vec![start, dur, end] + .into_iter() + .filter_map(|o| { + o.as_ref().map(|(_, formatted)| formatted.to_owned()) + }) + .collect::<Vec<_>>() + .join(" "); + + println!("testing {}", s); + + let (parse_start, parse_end) = parse_times(s); + + match start { + Some((dur, _)) => assert_eq!(*dur, parse_start.unwrap()), + None => assert_eq!(None, parse_start), + } + + match end { + Some((d, _)) => assert_eq!(*d, parse_end.unwrap()), + None => { + match dur { + Some((d, _)) => { + match start { + Some((s, _)) => assert_eq!(parse_end.unwrap(), *s + *d), + None => assert_eq!(None, parse_end), + } + }, + None => assert_eq!(None, parse_end), + } + } + } + }); + } +} + pub fn play(ctx: &mut Context, msg: &Message, mut args: Args) -> Result<()> { if args.len() == 0 { return _resume(ctx, msg); diff --git a/src/commands/playback/types.rs b/src/commands/playback/types.rs index 63479fd..380ee9d 100644 --- a/src/commands/playback/types.rs +++ b/src/commands/playback/types.rs @@ -5,12 +5,13 @@ use std::{ time::Duration, }; +use chrono::Duration as CDuration; use either::{Either, Left, Right}; use serenity::{ client::bridge::voice::ClientVoiceManager, model::id::ChannelId, prelude::*, - voice::{LockedAudio, ytdl}, + voice::{LockedAudio}, }; use typemap::Key; @@ -41,6 +42,8 @@ pub struct PlayArgs { pub data: Either<String, Vec<u8>>, pub initiator: String, pub sender_channel: ChannelId, + pub start: Option<CDuration>, + pub end: Option<CDuration>, } #[derive(Clone)] @@ -119,7 +122,7 @@ impl PlayQueue { let src = match item.data { Left(ref url) => { - match ytdl(url) { + match ytdl(url, item.start, item.end) { Ok(src) => src, Err(e) => { error!("bad link: {}; {:?}", url, e); @@ -160,3 +163,117 @@ impl PlayQueue { }); } } + +use std::{ + io::{ + Read, + Result as IoResult, + BufReader, + }, + process::{ + Command, + Stdio, + Child, + } +}; + +use serenity::{ + voice::{ + AudioSource, + pcm, + } +}; +use serde_json::Value; +use crate::Result; + +struct ChildContainer(Child); + +impl Read for ChildContainer { + fn read(&mut self, buffer: &mut [u8]) -> IoResult<usize> { + self.0.stdout.as_mut().unwrap().read(buffer) + } +} + +impl Drop for ChildContainer { + fn drop (&mut self) { + if let Err(e) = self.0.kill() { + debug!("[Voice] Error awaiting child process: {:?}", e); + } + } +} + + +// Copied from serenity +pub fn ytdl(uri: &str, start: Option<CDuration>, end: Option<CDuration>) -> Result<Box<AudioSource>> { + let args = [ + "-f", + "webm[abr>0]/bestaudio/best", + "--no-playlist", + "--print-json", + "--skip-download", + uri, + ]; + + let out = Command::new("youtube-dl") + .args(&args) + .stdin(Stdio::null()) + .output()?; + + if !out.status.success() { + return Err(VoiceError::YouTubeDLRun(out).into()); + } + + let value = serde_json::from_reader(&out.stdout[..])?; + let mut obj = match value { + Value::Object(obj) => obj, + other => return Err(VoiceError::YouTubeDLProcessing(other).into()), + }; + + let uri = match obj.remove("url") { + Some(v) => match v { + Value::String(uri) => uri, + other => return Err(VoiceError::YouTubeDLUrl(other).into()), + }, + None => return Err(VoiceError::YouTubeDLUrl(Value::Object(obj)).into()), + }; + + let start = start.unwrap_or(CDuration::zero()); + let start_str = format!("{:02}:{:02}:{:02}", start.num_hours(), start.num_minutes() % 60, start.num_seconds() % 60); + + let mut opts = vec! [ + "-f", + "s16le", + "-ac", + "2", // force stereo -- this may cause issues + "-ar", + "48000", + "-acodec", + "pcm_s16le", + "-ss", + &start_str, + ] + .into_iter() + .map(|s| s.to_owned()) + .collect::<Vec<_>>(); + + match end { + Some(e) => { + opts.push("-to".to_owned()); + opts.push(format!("{:02}:{:02}:{:02}", e.num_hours(), e.num_minutes() % 60, e.num_seconds() % 60)); + }, + _ => {}, + } + + opts.push("-".to_owned()); + + let command = Command::new("ffmpeg") + .arg("-i") + .arg(uri) + .args(opts) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .spawn()?; + + Ok(pcm(true, ChildContainer(command))) +} diff --git a/src/main.rs b/src/main.rs index d380308..feca86f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![feature(transpose_result)] +#![feature(impl_trait_in_bindings)] extern crate chrono; #[cfg(feature = "diesel")] @@ -16,6 +16,9 @@ extern crate serenity; extern crate sha1; extern crate typemap; extern crate url; +#[macro_use] extern crate itertools; +extern crate time; +extern crate serde_json; use std::{ thread, |
