aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNathan Perry <avaglir@gmail.com>2019-02-15 22:59:40 -0500
committerNathan Perry <avaglir@gmail.com>2019-02-15 22:59:40 -0500
commit61042c26faee164b51dda27561c9b67b34af8d9a (patch)
tree8d7cd258aab586c723a0690d95b5d0ed2bb88a56
parent6ad374d089d917cba53a76a40f9fb9bc5793ceb2 (diff)
initial implementation of video start/end times
-rw-r--r--Cargo.lock14
-rw-r--r--Cargo.toml9
-rw-r--r--src/commands/meme.rs2
-rw-r--r--src/commands/playback/mod.rs243
-rw-r--r--src/commands/playback/types.rs121
-rw-r--r--src/main.rs5
6 files changed, 386 insertions, 8 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a98b819..6025765 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 3f6509e..83a4f3f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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,