diff --git a/NadekoBot/Modules/Music.cs b/NadekoBot/Modules/Music.cs index 698ca2e3..009aa75e 100644 --- a/NadekoBot/Modules/Music.cs +++ b/NadekoBot/Modules/Music.cs @@ -1,4 +1,6 @@ -using System; +// Thanks to @Bloodskilled for providing most of the music code from his BooBot +// check out his server https://discord.gg/0aMlLYi2e2V7h2Kr +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -14,6 +16,7 @@ using System.Threading; using System.Diagnostics; using Discord.Legacy; using System.Net; +using System.Globalization; namespace NadekoBot.Modules { class Music : DiscordModule { @@ -203,134 +206,427 @@ namespace NadekoBot.Modules { } } - //new stuff - class AudioStreamer { - string sourceUrl; Channel statusTextChannel; - int totalSourceBytes; - public bool NetworkDone { get; private set; } - public int BytesSentToTranscoder { get; private set; } - public Stream PCMOutput { get; private set; } - CancellationTokenSource tokenSource1 = new CancellationTokenSource(); - CancellationTokenSource tokenSource2 = new CancellationTokenSource(); - Task transcoderTask; Task outputTask; - public AudioStreamer(string streamUrl, Channel statusTextChannel = null) { - sourceUrl = streamUrl; - this.statusTextChannel = statusTextChannel; - } - public void Start() { - Task.Run(async () => { - var bufferingStream = GetBufferingStream(sourceUrl); - Console.WriteLine("Buffering video..."); // Wait for some data to arrive - while (bufferingStream.Length < 1000 || NetworkDone) - await Task.Delay(500); - Console.WriteLine("buf done"); - Stream input, pcmOutput; - var ffmpegProcess = GetTranscoderStreams(out input, out pcmOutput); - PCMOutput = new DualStream(); // Keep pumping network stuff into the transcoder - transcoderTask = Task.Run(() => TranscoderFunc(bufferingStream, input, tokenSource1.Token)); - // Keep pumping transcoder output into the PCMOutput stream - outputTask = Task.Run(() => OutputFunc(pcmOutput, PCMOutput, tokenSource2.Token)); - // Wait until network stuff is all done - while (!NetworkDone) await Task.Delay(500); - // Then wait until we sent everything to the transcoder - while (BytesSentToTranscoder < totalSourceBytes) await Task.Delay(500); - // Then wait some more until it did everything and kill it - await Task.Delay(5000); + enum StreamTaskState { + Queued, + Playing, + Completed + } + + class StreamRequest { + static readonly string[] validFormats = { ".ogg", ".wav", ".mp3", ".webm", ".aac", ".mp4", ".flac" }; + + readonly DiscordClient client; + public readonly Server Server; + public readonly Channel Channel; + public Channel VoiceChannel; + public readonly User User; + public readonly string RequestText; + + const string DefaultTitle = ""; + public string Title = DefaultTitle; + public TimeSpan Length = TimeSpan.FromSeconds(0); + public string FileName; + + bool linkResolved; + public string StreamUrl; + public bool NetworkDone; + public long TotalSourceBytes; + + Stream bufferingStream; + StreamTask streamTask; + + public StreamTaskState State => streamTask?.State ?? StreamTaskState.Queued; + + + public StreamRequest(DiscordClient client, MessageEventArgs e, string text) { + this.client = client; + Server = e.Server; + Channel = e.Channel; + User = e.User; + RequestText = text.Trim(); + + FileName = "unresolved_" + Uri.EscapeUriString(RequestText); + + Task.Run(() => { try { - tokenSource1.Cancel(); - tokenSource2.Cancel(); - Console.WriteLine("Killing transcoder..."); - ffmpegProcess.Kill(); - } catch { } + ResolveLink(); + } catch (Exception ex) { + Console.WriteLine("Exception in ResolveLink: " + ex); + } }); } - async Task TranscoderFunc(Stream sourceStream, Stream targetStream, CancellationToken cancellationToken) { - byte[] buffer = new byte[0x4000]; - while (!NetworkDone && !cancellationToken.IsCancellationRequested) { - // When there is new stuff available on the network we want to get it instantly - int available = totalSourceBytes - BytesSentToTranscoder; - if (available > 0) { - int read = await sourceStream.ReadAsync(buffer, 0, Math.Min(available, buffer.Length), cancellationToken); - if (read > 0) { targetStream.Write(buffer, 0, read); - BytesSentToTranscoder += read; + + void ResolveLink() { + var url = RequestText; + + if (url.IndexOf("soundcloud", StringComparison.OrdinalIgnoreCase) != -1) { + var track = Services.SoundcloudService.GetTrackStreamUrl(url, out Title, out StreamUrl); + Length = TimeSpan.FromMilliseconds(track.Duration); + Title = track.Title; + FileName = Uri.EscapeUriString(Title) + ".mp3"; + + StartBuffering(); + linkResolved = true; + } else if (url.IndexOf("youtube", StringComparison.OrdinalIgnoreCase) != -1 || url.IndexOf("youtu.be", StringComparison.OrdinalIgnoreCase) != -1) { + try { + var infos = DownloadUrlResolver + .GetDownloadUrls(url.Trim()) + .Where(i => i.AudioType != AudioType.Unknown) + .ToArray(); + + if (infos.Length == 0) + throw new Exception("Could not load any video elements"); + + var info = infos + .GroupBy(x => x.AudioBitrate) // Create groups for audio bitrates + .OrderByDescending(x => x.Key) // Group with max bitrate first + .Take(1) // Only take one group + .SelectMany(x => x) // Unpack group container again + .OrderBy(x => x.Resolution) // take vid with smallest resolution + .First(); // First one + + StreamUrl = info.DownloadUrl; + Title = info.Title; + FileName = Uri.EscapeUriString(Title) + ".mp4"; + + StartBuffering(); + linkResolved = true; + } catch (Exception) { + // Send a message to the guy that queued that + Channel.SendMessage(":warning: " + User.Mention + " Cannot load youtube url: `This video is not available in your country` or the url is corrupted somehow..."); + Console.WriteLine("Cannot parse youtube url: " + url); + Cancel(); + } + } else { + // Is it a direct link oO ?? + var format = validFormats.FirstOrDefault(f => url.EndsWith(f)); + if (format == null) { + Console.WriteLine("Direct link: \"" + url + "\" does not end with a valid extension"); + return; + } + + StreamUrl = url; + Title = url; + + StartBuffering(); + linkResolved = true; + } + } + + internal void StartBuffering() { + var folder = "StreamBuffers"; + Directory.CreateDirectory(folder); + var fullPath = Path.Combine(folder, FileName); + + FileStream fileStream; + FileStream readStream; + try { + if (File.Exists(fullPath) && new FileInfo(fullPath).Length > 1024 * 2) { + NetworkDone = true; + TotalSourceBytes = new FileInfo(fullPath).Length; + bufferingStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + if (Length.TotalSeconds < double.Epsilon) + Length = GetFileLength(fullPath); + return; + } + + // Open a new file to stream into + fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.Read); + bufferingStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } catch (Exception ex) { + Console.WriteLine("Exception while creating or opening stream buffers: " + ex); + return; + } + + + + Task.Run(() => { + AutoResetDelay fileLengthCheckDelay = new AutoResetDelay(500); + int byteCounter = 0; + bool fileLengthDetermined = false; + + try { + var webClient = new WebClient(); + var networkStream = webClient.OpenRead(StreamUrl); + + if (networkStream == null) + return; + + byte[] buffer = new byte[0x1000]; + while (true) { + int read = networkStream.Read(buffer, 0, buffer.Length); + if (read <= 0) + break; + byteCounter += read; + TotalSourceBytes += read; + fileStream.Write(buffer, 0, read); + + if (TotalSourceBytes > 1024 * 2 && Length.TotalSeconds < 0.1 && fileLengthCheckDelay.IsReady) { + Length = GetFileLength(fullPath); + } } - } - else await Task.Delay(1); + } catch (Exception ex) { + Console.WriteLine("Exception while buffering (network->file): " + ex); + } + + fileStream.Close(); + NetworkDone = true; + Console.WriteLine("net: done. ({0} read)", byteCounter); + }); + } + + internal void Start() { + if (State != StreamTaskState.Queued) + return; + + Stopwatch resolveTimer = Stopwatch.StartNew(); + + if (!linkResolved || bufferingStream == null) + Channel.SendMessage($":musical_note: Resolving link...\r\n:warning: `Keep in mind that other people can 'steal' the bot by just starting a stream command in their own server...`\r\n"); + + while (resolveTimer.ElapsedMilliseconds < 8000) { + if (bufferingStream != null) + break; + Thread.Sleep(50); + } + + if (bufferingStream == null) { + Console.WriteLine("Buffering stream was not set! Can't play track!"); + streamTask = new StreamTask(client, this, null); + streamTask.CancelStreaming(); + return; + } + + streamTask = new StreamTask(client, this, bufferingStream); + + VoiceChannel = GetVoiceChannelForUser(User); + if (VoiceChannel == null) { + Channel.SendMessage($":warning: {User.Mention} `I can't find you in any voice channel. Join one, then try again...`"); + streamTask.CancelStreaming(); // just to set the state to done + return; + } + + // Go! + streamTask.StartStreaming(); + } + + internal void Cancel() { + if (State == StreamTaskState.Completed) + return; + + if (streamTask == null) + streamTask = new StreamTask(client, this, bufferingStream); + streamTask.CancelStreaming(); + } + + + Channel GetVoiceChannelForUser(User user) { + return client.Servers.SelectMany(s => s.VoiceChannels).FirstOrDefault(c => c.Users.Any(u => u.Id == user.Id)); + } + + public string GetFormattedTitle() { + if (Length.TotalSeconds < double.Epsilon) + Length = GetFileLength(FileName); + + if (Title != DefaultTitle) + return $"**{Title.Replace('*', '°')}** *({Length.ToString()})*"; + + // put into <> when it contains a domain + if (StreamUrl == null) + return "<" + RequestText + ">"; + if (StreamUrl.Contains("http:") || StreamUrl.Contains("https:")) + return "<" + StreamUrl.Trim() + ">"; + return StreamUrl; + } + + public static TimeSpan GetFileLength(string fileName) { + try { + var startInfo = new ProcessStartInfo("ffprobe", $"-i \"{fileName}\" -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1"); + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + using (var process = Process.Start(startInfo)) { + var lengthLine = process.StandardOutput.ReadLine(); + double result; + if (double.TryParse(lengthLine, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result)) { + int integerPart = (int)Math.Round(result); + return TimeSpan.FromSeconds(integerPart); + } else + return TimeSpan.Zero; + } + } catch (Exception) { + Console.WriteLine("Exception while determining file play-time"); + return TimeSpan.Zero; + } + } + } + class TranscodingTask { + readonly StreamRequest streamRequest; + readonly Stream bufferingStream; + + public long BytesSentToTranscoder { get; private set; } + public DualStream PCMOutput { get; private set; } + public long ReadyBytesLeft => PCMOutput?.writePos - PCMOutput?.readPos ?? 0; + + readonly CancellationTokenSource tokenSource = new CancellationTokenSource(); + + Task transcoderTask; + Task outputTask; + + public TranscodingTask(StreamRequest streamRequest, Stream bufferingStream) { + this.streamRequest = streamRequest; + this.bufferingStream = bufferingStream; + } + + public void Start() { + Task.Run(async () => { + // Wait for some data to arrive + while (true) { + if (streamRequest.NetworkDone) + break; + if (bufferingStream.Length > 1024 * 3) + break; + await Task.Delay(100); + } + + Stream input, pcmOutput; + var ffmpegProcess = GetTranscoderStreams(out input, out pcmOutput); + + PCMOutput = new DualStream(); + + // Keep pumping network stuff into the transcoder + transcoderTask = Task.Run(() => TranscoderFunc(bufferingStream, input, tokenSource.Token), tokenSource.Token); + + // Keep pumping transcoder output into the PCMOutput stream + outputTask = Task.Run(() => OutputFunc(pcmOutput, PCMOutput, tokenSource.Token), tokenSource.Token); + + // Wait until network stuff is all done + while (!streamRequest.NetworkDone) + await Task.Delay(200); + + // Then wait until we sent everything to the transcoder + while (BytesSentToTranscoder < streamRequest.TotalSourceBytes) + await Task.Delay(200); + + // Then wait some more until it did everything and kill it + await Task.Delay(5000); + + try { + tokenSource.Cancel(); + bufferingStream.Close(); + + Console.WriteLine("Killing transcoder..."); + ffmpegProcess.Kill(); + } catch { + } + }); + } + + async Task TranscoderFunc(Stream sourceStream, Stream transcoderInput, CancellationToken cancellationToken) { + try { + byte[] buffer = new byte[1024]; + while (true) { + if (cancellationToken.IsCancellationRequested) + break; + if (BytesSentToTranscoder >= streamRequest.TotalSourceBytes) + break; + + // When there is new stuff available on the network we want to get it instantly + long available = streamRequest.TotalSourceBytes - BytesSentToTranscoder; + + double availableRingSpace = PCMOutput.Length / (double)PCMOutput.Capacity; + + // How much data is in the final output buffer? + // We dont want to transcode too much in advance + if (available > 0 && availableRingSpace < 1) { + int read = await sourceStream.ReadAsync(buffer, 0, (int)Math.Min(available, buffer.LongLength), cancellationToken); + if (read > 0) { + // Write to transcoder + transcoderInput.Write(buffer, 0, read); + BytesSentToTranscoder += read; + } + } else { + // We have enough data transcoded already. Stall a bit so we dont do too much! + await Task.Delay(100); + } + } + } catch (Exception ex) { + Console.WriteLine(ex.ToString()); } Console.WriteLine("TranscoderFunc stopped"); } - async Task OutputFunc(Stream sourceStream, Stream targetStream, CancellationToken cancellationToken) { - byte[] buffer = new byte[0x4000]; - while (!cancellationToken.IsCancellationRequested) { - // When there is new stuff available on the network we want to get it instantly - int read = await sourceStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); - if (read > 0) targetStream.Write(buffer, 0, read); + + static async Task OutputFunc(Stream sourceStream, DualStream targetBuffer, CancellationToken cancellationToken) { + try { + byte[] buffer = new byte[1024]; + while (!cancellationToken.IsCancellationRequested) { + // When there is new stuff available on the network we want to get it instantly + int read = await sourceStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + if (read > 0) { + targetBuffer.Write(buffer, 0, read); + } + } + } catch (Exception ex) { + Console.WriteLine(ex.ToString()); } + Console.WriteLine("OutputFunc stopped"); } + internal static Process GetTranscoderStreams(out Stream input, out Stream pcmOutput) { - Process p = Process.Start(new ProcessStartInfo { - FileName = "ffmpeg", - Arguments = "-i pipe:0 -f s16le -ar 48000 -ac 2 pipe:1", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardInput = true, - CreateNoWindow = true, - }); + Process p = null; + Exception ex = null; + + try { + p = Process.Start(new ProcessStartInfo { + FileName = "ffmpeg", + Arguments = "-i pipe:0 -f s16le -ar 48000 -ac 2 pipe:1", + UseShellExecute = false, + RedirectStandardError = false, + RedirectStandardOutput = true, + RedirectStandardInput = true, + CreateNoWindow = true, + }); + } catch (Exception exInner) { + ex = exInner; + } + + if (p == null || ex != null) { + input = null; + pcmOutput = null; + Console.WriteLine("Could not start ffmpeg: " + (ex?.Message ?? "")); + return null; + } + pcmOutput = p.StandardOutput.BaseStream; input = p.StandardInput.BaseStream; return p; } - Stream GetBufferingStream(string streamUrl) { - var memoryStream = new DualStream(); - Task.Run(() => { - int byteCounter = 0; - try { - var webClient = new WebClient(); - var networkStream = webClient.OpenRead(streamUrl); - if (networkStream == null) return; - byte[] buffer = new byte[0x1000]; - while (true) { - int read = networkStream.Read(buffer, 0, buffer.Length); - if (read <= 0) break; - byteCounter += read; - totalSourceBytes += read; - memoryStream.Write(buffer, 0, read); - } - } catch (Exception ex) { - Console.WriteLine("Exception while reading network stream: " + ex); - } - NetworkDone = true; Console.WriteLine("net: done. ({0} read)", byteCounter); - }); - return memoryStream; - } - async void Write(string message) { - Console.WriteLine(message); - } + public void Cancel() { - tokenSource1.Cancel(); - tokenSource2.Cancel(); - NetworkDone = true; - BytesSentToTranscoder = totalSourceBytes; + tokenSource.Cancel(); + BytesSentToTranscoder = streamRequest.TotalSourceBytes; } } public class DualStream : MemoryStream { - long readPosition; - long writePosition; + public long readPos; + public long writePos; public override int Read(byte[] buffer, int offset, int count) { int read; lock (this) { - Position = readPosition; + Position = readPos; read = base.Read(buffer, offset, count); - readPosition = Position; + readPos = Position; } return read; } public override void Write(byte[] buffer, int offset, int count) { lock (this) { - Position = writePosition; + Position = writePos; base.Write(buffer, offset, count); - writePosition = Position; + writePos = Position; } } } diff --git a/NadekoBot/NadekoBot.cs b/NadekoBot/NadekoBot.cs index 2063a8d0..2031bd5f 100644 --- a/NadekoBot/NadekoBot.cs +++ b/NadekoBot/NadekoBot.cs @@ -99,8 +99,12 @@ namespace NadekoBot static bool repliedRecently = false; private static async void Client_MessageReceived(object sender, MessageEventArgs e) { if (e.Server != null) return; + try { + (await client.GetInvite(e.Message.Text))?.Accept(); + } catch (Exception) { } + if (repliedRecently = !repliedRecently) { - await e.Send("You can type `-h` or `-help` or `@MyName help` in any of the channels I am in and I will send you a message with my commands.\n Or you can find out what i do here: https://github.com/Kwoth/NadekoBot\nIf you don't want me on your server, you can simply ban me ;("); + await e.Send("You can type `-h` or `-help` or `@MyName help` in any of the channels I am in and I will send you a message with my commands.\n Or you can find out what i do here: https://github.com/Kwoth/NadekoBot\nYou can also just send me an invite link to a server and I will join it.\nIf you don't want me on your server, you can simply ban me ;("); Timer t = new Timer(); t.Interval = 2000; t.Start();