From 966a4934552a43d2a1de0e314bacd7bada0a6845 Mon Sep 17 00:00:00 2001 From: Nathan Perry Date: Sat, 16 Feb 2019 14:52:54 -0500 Subject: clean up project structure - Move audio-related code into its own top-level module, separating out playback commands into their own file in `commands`. - Rename `sound` commands module to `sound_levels`. --- src/audio/mod.rs | 81 +++++++++++++++++++ src/audio/play_queue.rs | 137 +++++++++++++++++++++++++++++++++ src/audio/timeutil.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++++ src/audio/ytdl.rs | 117 ++++++++++++++++++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 src/audio/mod.rs create mode 100644 src/audio/play_queue.rs create mode 100644 src/audio/timeutil.rs create mode 100644 src/audio/ytdl.rs (limited to 'src/audio') diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..a7f3e83 --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use either::Either; +use typemap::Key; +use chrono::Duration; + +use serenity::{ + model::{ + id::ChannelId, + }, + prelude::*, + client::bridge::voice::ClientVoiceManager, + voice::LockedAudio, +}; + +use crate::{ + must_env_lookup, + Result, +}; + +pub use self::timeutil::parse_times; +pub use self::ytdl::ytdl; +pub use self::play_queue::PlayQueue; + +mod timeutil; +mod ytdl; +mod play_queue; + +pub trait CtxExt { + fn currently_playing(&self) -> bool; + fn users_listening(&self) -> Result; +} + +impl CtxExt for Context { + fn currently_playing(&self) -> bool { + let queue_lock = self.data.lock().get::().cloned().unwrap(); + let play_queue = queue_lock.read().unwrap(); + play_queue.playing.is_none() + } + + fn users_listening(&self) -> Result { + let channel_id = ChannelId(must_env_lookup::("VOICE_CHANNEL")); + let channel = channel_id.to_channel()?; + let res = channel.guild() + .and_then(|ch| ch.read().guild()) + .map(|g| (&g.read().voice_states) + .into_iter() + .any(|(_, state)| state.channel_id == Some(channel_id))) + .unwrap_or(false); + + Ok(res) + } +} + +pub struct VoiceManager; + +impl Key for VoiceManager { + type Value = Arc>; +} + +impl VoiceManager { + pub fn register(c: &mut Client) { + let mut data = c.data.lock(); + data.insert::(Arc::clone(&c.voice_manager)); + } +} + +#[derive(Clone, Debug)] +pub struct PlayArgs { + pub data: Either>, + pub initiator: String, + pub sender_channel: ChannelId, + pub start: Option, + pub end: Option, +} + +#[derive(Clone)] +pub struct CurrentItem { + pub init_args: PlayArgs, + pub audio: LockedAudio, +} diff --git a/src/audio/play_queue.rs b/src/audio/play_queue.rs new file mode 100644 index 0000000..6f3a130 --- /dev/null +++ b/src/audio/play_queue.rs @@ -0,0 +1,137 @@ +use std::{ + sync::{Arc, RwLock}, + thread, + collections::VecDeque, + time::Duration, +}; + +use typemap::Key; +use either::{Left, Right}; +use serenity::prelude::*; + +use crate::{ + audio::{ + CurrentItem, + PlayArgs, + ytdl, + }, + commands::{ + sound_levels::DEFAULT_VOLUME, + send, + }, + must_env_lookup, + TARGET_GUILD_ID, +}; + +#[derive(Clone)] +pub struct PlayQueue { + pub queue: VecDeque, + pub playing: Option, + pub volume: f32, +} + +impl Key for PlayQueue { + type Value = Arc>; +} + +impl PlayQueue { + pub fn new() -> Self { + PlayQueue { + queue: VecDeque::new(), + playing: None, + volume: DEFAULT_VOLUME, + } + } + + pub fn register(c: &mut Client) { + let voice_manager = Arc::clone(&c.voice_manager); + + let mut data = c.data.lock(); + let queue = Arc::new(RwLock::new(PlayQueue::new())); + + data.insert::(Arc::clone(&queue)); + + thread::spawn(move || { + let queue_lck = Arc::clone(&queue); + let voice_manager = voice_manager; + + loop { + thread::sleep(Duration::from_millis(250)); + let (queue_is_empty, queue_has_playing) = { + let queue = queue_lck.read().unwrap(); + + let allow_continue = queue.playing.clone().map_or(false, |x| !x.audio.lock().finished); + + if allow_continue { + continue; + } + + (queue.queue.is_empty(), queue.playing.is_some()) + }; + + if queue_is_empty { + if queue_has_playing { + let mut queue = queue_lck.write().unwrap(); + + assert!({ + let audio_lck = queue.playing.clone().unwrap().audio; + let audio = audio_lck.lock(); + audio.finished + }); + + queue.playing = None; + + let mut manager = voice_manager.lock(); + manager.leave(*TARGET_GUILD_ID); + debug!("disconnected because playback finished"); + } + continue; + } + + let mut queue = queue_lck.write().unwrap(); + let item = queue.queue.pop_front().unwrap(); + + let src = match item.data { + Left(ref url) => { + match ytdl(url, item.start, item.end) { + Ok(src) => src, + Err(e) => { + error!("bad link: {}; {:?}", url, e); + let _ = send(item.sender_channel, "what the fuck", false); + continue; + } + } + }, + Right(ref vec) => { + ::serenity::voice::opus(true, ::std::io::Cursor::new(vec.clone())) + } + }; + + + let mut manager = voice_manager.lock(); + let handler = manager.join(*TARGET_GUILD_ID, must_env_lookup::("VOICE_CHANNEL")); + + match handler { + Some(handler) => { + let audio = handler.play_only(src); + { + audio.lock().volume(queue.volume); + } + + queue.playing = Some(CurrentItem { + init_args: item, + audio, + }); + + debug!("playing new song"); + }, + None => { + error!("couldn't join channel"); + let _ = send(item.sender_channel, "something happened somewhere somehow.", false); + } + } + } + }); + } +} + diff --git a/src/audio/timeutil.rs b/src/audio/timeutil.rs new file mode 100644 index 0000000..d0bd9d5 --- /dev/null +++ b/src/audio/timeutil.rs @@ -0,0 +1,201 @@ +use regex::{ + Regex, + Match, +}; +use chrono::Duration; + +lazy_static! { + static ref START_REGEX: Regex = + Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); + + static ref DUR_REGEX: Regex = + Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); + + static ref END_REGEX: Regex = + Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); +} + +pub fn parse_times>(s: A) -> (Option, Option) { + fn parse_match(m: Option) -> u64 { + m.and_then(|s| s.as_str().parse::().ok()).unwrap_or(0) + } + + fn parse_captures>(r: &Regex, s: B) -> Option { + 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 { + 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>(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)) + }) + } + + 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>> + }).unwrap_or_else(|| Box::new(::std::iter::once(None))) + }); + + result.collect::>>() + }) + .collect::>>>(); + + 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::>() + .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), + } + } + } + }); + } +} diff --git a/src/audio/ytdl.rs b/src/audio/ytdl.rs new file mode 100644 index 0000000..d16d166 --- /dev/null +++ b/src/audio/ytdl.rs @@ -0,0 +1,117 @@ +/// This module is entirely adapted from the relevant code in Serenity. + +use std::{ + io::{ + Read, + Result as IoResult, + }, + process::{ + Command, + Stdio, + Child, + }, +}; + +use chrono::Duration; +use serde_json::Value; + +use serenity::{ + voice::{ + AudioSource, + pcm, + VoiceError, + } +}; + +use crate::Result; + + +struct ChildContainer(Child); + +impl Read for ChildContainer { + fn read(&mut self, buffer: &mut [u8]) -> IoResult { + 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); + } + } +} + +pub fn ytdl(uri: &str, start: Option, end: Option) -> Result> { + 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(Duration::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::>(); + + 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))) +} -- cgit v1.3.1