Completely rewrote music.
#worth 8 hours
This commit is contained in:
parent
e3cafe5b8e
commit
4251da0a86
@ -174,5 +174,15 @@ namespace NadekoBot.Extensions
|
|||||||
// Step 7
|
// Step 7
|
||||||
return d[n, m];
|
return d[n, m];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int KiB(this int value) => value * 1024;
|
||||||
|
public static int KB(this int value) => value * 1000;
|
||||||
|
|
||||||
|
public static int MiB(this int value) => value.KiB() * 1024;
|
||||||
|
public static int MB(this int value) => value.KB() * 1000;
|
||||||
|
|
||||||
|
public static int GiB(this int value) => value.MiB() * 1024;
|
||||||
|
public static int GB(this int value) => value.MB() * 1000;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
48
NadekoBot/Classes/Music/MusicControls.cs
Normal file
48
NadekoBot/Classes/Music/MusicControls.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.Audio;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NadekoBot.Classes.Music {
|
||||||
|
public class MusicControls {
|
||||||
|
public bool NextSong = false;
|
||||||
|
public IAudioClient Voice;
|
||||||
|
public Channel VoiceChannel;
|
||||||
|
public bool Pause = false;
|
||||||
|
public List<StreamRequest> SongQueue = new List<StreamRequest>();
|
||||||
|
public StreamRequest CurrentSong;
|
||||||
|
|
||||||
|
public bool IsPaused { get; internal set; }
|
||||||
|
|
||||||
|
public MusicControls() {
|
||||||
|
Task.Run(async () => {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (CurrentSong == null || CurrentSong.State == StreamState.Completed) {
|
||||||
|
LoadNextSong();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Console.WriteLine("Bug in music task run. " + e);
|
||||||
|
}
|
||||||
|
await Task.Delay(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadNextSong() {
|
||||||
|
if (SongQueue.Count == 0) {
|
||||||
|
if (CurrentSong != null)
|
||||||
|
CurrentSong.Cancel();
|
||||||
|
CurrentSong = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CurrentSong = SongQueue[0];
|
||||||
|
SongQueue.RemoveAt(0);
|
||||||
|
CurrentSong.Start();
|
||||||
|
Console.WriteLine("starting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
260
NadekoBot/Classes/Music/StreamRequest.cs
Normal file
260
NadekoBot/Classes/Music/StreamRequest.cs
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Discord;
|
||||||
|
using Discord.Commands;
|
||||||
|
using Discord.Audio;
|
||||||
|
using YoutubeExtractor;
|
||||||
|
using NadekoBot.Modules;
|
||||||
|
using System.IO;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using NadekoBot.Extensions;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace NadekoBot.Classes.Music {
|
||||||
|
public enum StreamState {
|
||||||
|
Resolving,
|
||||||
|
Queued,
|
||||||
|
Buffering, //not using it atm
|
||||||
|
Playing,
|
||||||
|
Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StreamRequest {
|
||||||
|
public Channel Channel { get; }
|
||||||
|
public Server Server { get; }
|
||||||
|
public User User { get; }
|
||||||
|
public string Query { get; }
|
||||||
|
|
||||||
|
private MusicStreamer musicStreamer = null;
|
||||||
|
public StreamState State => musicStreamer?.State ?? StreamState.Resolving;
|
||||||
|
|
||||||
|
public StreamRequest(CommandEventArgs e, string query) {
|
||||||
|
if (e == null)
|
||||||
|
throw new ArgumentNullException(nameof(e));
|
||||||
|
if (query == null)
|
||||||
|
throw new ArgumentNullException(nameof(query));
|
||||||
|
if (e.User.VoiceChannel == null)
|
||||||
|
throw new NullReferenceException("Voicechannel is null.");
|
||||||
|
|
||||||
|
this.Server = e.Server;
|
||||||
|
this.Channel = e.User.VoiceChannel;
|
||||||
|
this.User = e.User;
|
||||||
|
this.Query = query;
|
||||||
|
ResolveStreamLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ResolveStreamLink() =>
|
||||||
|
Task.Run(() => {
|
||||||
|
Console.WriteLine("Resolving video link");
|
||||||
|
var video = DownloadUrlResolver.GetDownloadUrls(Searches.FindYoutubeUrlByKeywords(Query))
|
||||||
|
.Where(v => v.AdaptiveType == AdaptiveType.Audio)
|
||||||
|
.OrderByDescending(v => v.AudioBitrate).FirstOrDefault();
|
||||||
|
|
||||||
|
if (video == null) // do something with this error
|
||||||
|
throw new Exception("Could not load any video elements");
|
||||||
|
|
||||||
|
Title = video.Title;
|
||||||
|
|
||||||
|
musicStreamer = new MusicStreamer(this, video.DownloadUrl, Channel);
|
||||||
|
OnQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
internal void Pause() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title { get; internal set; } = String.Empty;
|
||||||
|
|
||||||
|
public Action OnQueued = null;
|
||||||
|
public Action OnBuffering = null;
|
||||||
|
public Action OnStarted = null;
|
||||||
|
public Action OnCompleted = null;
|
||||||
|
|
||||||
|
//todo maybe add remove, in order to create remove at position command
|
||||||
|
|
||||||
|
internal void Cancel() {
|
||||||
|
musicStreamer?.StopPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task Start() =>
|
||||||
|
Task.Run(async () => {
|
||||||
|
Console.WriteLine("Start called.");
|
||||||
|
|
||||||
|
int attemptsLeft = 7;
|
||||||
|
//wait for up to 7 seconds to resolve a link
|
||||||
|
while (State == StreamState.Resolving) {
|
||||||
|
await Task.Delay(1000);
|
||||||
|
Console.WriteLine("Resolving...");
|
||||||
|
if (--attemptsLeft == 0) {
|
||||||
|
Console.WriteLine("Resolving timed out.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await musicStreamer.StartPlayback();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Console.WriteLine("Error in start playback." + ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MusicStreamer {
|
||||||
|
private Channel channel;
|
||||||
|
private DualStream buffer;
|
||||||
|
|
||||||
|
public StreamState State { get; internal set; }
|
||||||
|
public string Url { get; private set; }
|
||||||
|
|
||||||
|
StreamRequest parent;
|
||||||
|
private readonly object _bufferLock = new object();
|
||||||
|
private CancellationTokenSource cancelSource;
|
||||||
|
|
||||||
|
public MusicStreamer(StreamRequest parent, string directUrl, Channel channel) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.channel = channel;
|
||||||
|
this.buffer = new DualStream();
|
||||||
|
this.Url = directUrl;
|
||||||
|
Console.WriteLine("Created new streamer");
|
||||||
|
State = StreamState.Queued;
|
||||||
|
cancelSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
//todo app will crash if song is too long, should load only next 20-ish seconds
|
||||||
|
private async Task BufferSong() {
|
||||||
|
Console.WriteLine("Buffering...");
|
||||||
|
//start feeding the buffer
|
||||||
|
var p = Process.Start(new ProcessStartInfo {
|
||||||
|
FileName = "ffmpeg",
|
||||||
|
Arguments = $"-i {Url} -f s16le -ar 48000 -ac 2 pipe:1",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardError = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
while (buffer.writePos - buffer.readPos > 2.MB() && !cancelSource.IsCancellationRequested) {
|
||||||
|
Console.WriteLine($"Got over 2MB more, waiting. Data length:{buffer.Length * 1.0f / 1.MB()}MB");
|
||||||
|
await Task.Delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelSource.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
if (buffer.Length > 5.MB()) { // if buffer is over 10 MB, create new one
|
||||||
|
Console.WriteLine("Buffer over 10 megs, clearing.");
|
||||||
|
|
||||||
|
var skip = 2.MB();
|
||||||
|
byte[] data = buffer.ToArray().Skip(skip).ToArray();
|
||||||
|
|
||||||
|
lock (_bufferLock) {
|
||||||
|
var newWritePos = buffer.writePos - skip;
|
||||||
|
var newReadPos = buffer.readPos - skip;
|
||||||
|
var newPos = buffer.Position - skip;
|
||||||
|
|
||||||
|
buffer = new DualStream();
|
||||||
|
buffer.Write(data, 0, data.Length);
|
||||||
|
|
||||||
|
buffer.writePos = newWritePos;
|
||||||
|
buffer.readPos = newReadPos;
|
||||||
|
buffer.Position = newPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf = new byte[1024];
|
||||||
|
int read = 0;
|
||||||
|
read = await p.StandardOutput.BaseStream.ReadAsync(buf, 0, 1024);
|
||||||
|
|
||||||
|
if (read == 0) {
|
||||||
|
Console.WriteLine("Didn't read anything from the stream");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await buffer.WriteAsync(buf, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Task StartPlayback() =>
|
||||||
|
Task.Run(async () => {
|
||||||
|
Console.WriteLine("Starting playback.");
|
||||||
|
State = StreamState.Playing;
|
||||||
|
if (parent.OnBuffering != null)
|
||||||
|
parent.OnBuffering();
|
||||||
|
Task.Run(async () => await BufferSong());
|
||||||
|
await Task.Delay(2000,cancelSource.Token);
|
||||||
|
if (parent.OnStarted != null)
|
||||||
|
parent.OnStarted();
|
||||||
|
Console.WriteLine("Prebuffering complete.");
|
||||||
|
//for now wait for 3 seconds before starting playback.
|
||||||
|
|
||||||
|
var audio = NadekoBot.client.Audio();
|
||||||
|
|
||||||
|
var voiceClient = await audio.Join(channel);
|
||||||
|
int blockSize = 1920 * NadekoBot.client.Audio().Config.Channels;
|
||||||
|
byte[] voiceBuffer = new byte[blockSize];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
int readCount = 0;
|
||||||
|
lock (_bufferLock) {
|
||||||
|
readCount = buffer.Read(voiceBuffer, 0, voiceBuffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readCount == 0) {
|
||||||
|
Console.WriteLine("Nothing to read, stream finished.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// while (MusicControls.IsPaused && !cancelSource.IsCancellationRequested)
|
||||||
|
// await Task.Delay(100);
|
||||||
|
|
||||||
|
if (cancelSource.IsCancellationRequested) {
|
||||||
|
Console.WriteLine("Canceled");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceClient.Send(voiceBuffer, 0, voiceBuffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceClient.Wait();
|
||||||
|
State = StreamState.Completed;
|
||||||
|
Console.WriteLine("Song completed.");
|
||||||
|
if (parent.OnCompleted != null)
|
||||||
|
parent.OnCompleted();
|
||||||
|
});
|
||||||
|
|
||||||
|
internal void StopPlayback() {
|
||||||
|
Console.WriteLine("Stopping playback");
|
||||||
|
State = StreamState.Completed;
|
||||||
|
if(cancelSource.Token.CanBeCanceled)
|
||||||
|
cancelSource.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DualStream : MemoryStream {
|
||||||
|
public long readPos;
|
||||||
|
public long writePos;
|
||||||
|
|
||||||
|
public DualStream() : base() { }
|
||||||
|
public DualStream(byte[] data) : base(data) { }
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) {
|
||||||
|
int read;
|
||||||
|
lock (this) {
|
||||||
|
Position = readPos;
|
||||||
|
read = base.Read(buffer, offset, count);
|
||||||
|
readPos = Position;
|
||||||
|
}
|
||||||
|
return read;
|
||||||
|
}
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) {
|
||||||
|
lock (this) {
|
||||||
|
Position = writePos;
|
||||||
|
base.Write(buffer, offset, count);
|
||||||
|
writePos = Position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,6 +1,4 @@
|
|||||||
// Thanks to @Bloodskilled for providing most of the music code from his BooBot
|
using System;
|
||||||
// check out his server https://discord.gg/0aMlLYi2e2V7h2Kr
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -16,70 +14,45 @@ using NadekoBot.Extensions;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using NadekoBot.Classes.Music;
|
||||||
|
|
||||||
namespace NadekoBot.Modules {
|
namespace NadekoBot.Modules {
|
||||||
class Music : DiscordModule {
|
class Music : DiscordModule {
|
||||||
|
|
||||||
public class MusicControls {
|
|
||||||
public bool NextSong = false;
|
|
||||||
public IAudioClient Voice;
|
|
||||||
public Channel VoiceChannel;
|
|
||||||
public bool Pause = false;
|
|
||||||
public List<StreamRequest> SongQueue = new List<StreamRequest>();
|
|
||||||
public StreamRequest CurrentSong;
|
|
||||||
|
|
||||||
public MusicControls() {
|
|
||||||
Task.Run(async () => {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
if (CurrentSong == null || CurrentSong.State == StreamTaskState.Completed) {
|
|
||||||
LoadNextSong();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Console.WriteLine("Bug in music task run. " + e);
|
|
||||||
}
|
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
CleanMusicPlayers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadNextSong() {
|
|
||||||
if (SongQueue.Count == 0 || !SongQueue[0].LinkResolved) {
|
|
||||||
if (CurrentSong != null)
|
|
||||||
CurrentSong.Cancel();
|
|
||||||
CurrentSong = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CurrentSong = SongQueue[0];
|
|
||||||
SongQueue.RemoveAt(0);
|
|
||||||
CurrentSong.Start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ConcurrentDictionary<Server, MusicControls> musicPlayers = new ConcurrentDictionary<Server, MusicControls>();
|
public static ConcurrentDictionary<Server, MusicControls> musicPlayers = new ConcurrentDictionary<Server, MusicControls>();
|
||||||
|
|
||||||
|
internal static void CleanMusicPlayers() {
|
||||||
public Music() : base() {
|
foreach (var mp in musicPlayers
|
||||||
//commands.Add(new PlayMusic());
|
.Where(kvp => kvp.Value.CurrentSong == null
|
||||||
|
&& kvp.Value.SongQueue.Count == 0)) {
|
||||||
|
var val = mp.Value;
|
||||||
|
musicPlayers.TryRemove(mp.Key, out val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//m r,radio - init
|
internal static string GetMusicStats() {
|
||||||
//m n,next - next in que
|
var servers = 0;
|
||||||
//m p,pause - pauses, call again to unpause
|
var queued = 0;
|
||||||
//m yq [key_words] - queue from yt by keywords
|
musicPlayers.ForEach(kvp => {
|
||||||
//m s,stop - stop
|
var mp = kvp.Value;
|
||||||
//m sh - shuffle songs
|
if (mp.SongQueue.Count > 0 || mp.CurrentSong != null)
|
||||||
//m pl - current playlist
|
queued += mp.SongQueue.Count + 1;
|
||||||
|
servers++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Playing {queued} songs across {servers} servers.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Music() : base() {
|
||||||
|
System.Timers.Timer cleaner = new System.Timers.Timer();
|
||||||
|
cleaner.Elapsed += (s, e) => CleanMusicPlayers();
|
||||||
|
cleaner.Interval = 10000;
|
||||||
|
cleaner.Start();
|
||||||
|
}
|
||||||
|
|
||||||
public override void Install(ModuleManager manager) {
|
public override void Install(ModuleManager manager) {
|
||||||
var client = NadekoBot.client;
|
var client = NadekoBot.client;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
manager.CreateCommands("!m", cgb => {
|
manager.CreateCommands("!m", cgb => {
|
||||||
//queue all more complex commands
|
//queue all more complex commands
|
||||||
commands.ForEach(cmd => cmd.Init(cgb));
|
commands.ForEach(cmd => cmd.Init(cgb));
|
||||||
@ -89,12 +62,7 @@ namespace NadekoBot.Modules {
|
|||||||
.Description("Goes to the next song in the queue.")
|
.Description("Goes to the next song in the queue.")
|
||||||
.Do(e => {
|
.Do(e => {
|
||||||
if (musicPlayers.ContainsKey(e.Server) == false || (musicPlayers[e.Server]?.CurrentSong) == null) return;
|
if (musicPlayers.ContainsKey(e.Server) == false || (musicPlayers[e.Server]?.CurrentSong) == null) return;
|
||||||
var CurrentSong = musicPlayers[e.Server].CurrentSong;
|
musicPlayers[e.Server].CurrentSong.Cancel();
|
||||||
CurrentSong.Cancel();
|
|
||||||
CurrentSong = musicPlayers[e.Server].SongQueue.Take(1).FirstOrDefault();
|
|
||||||
if (CurrentSong != null) {
|
|
||||||
CurrentSong.Start();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cgb.CreateCommand("s")
|
cgb.CreateCommand("s")
|
||||||
@ -115,19 +83,55 @@ namespace NadekoBot.Modules {
|
|||||||
.Description("Pauses the song")
|
.Description("Pauses the song")
|
||||||
.Do(async e => {
|
.Do(async e => {
|
||||||
if (musicPlayers.ContainsKey(e.Server) == false) return;
|
if (musicPlayers.ContainsKey(e.Server) == false) return;
|
||||||
await e.Send("Not yet implemented.");
|
await e.Send("This feature is coming VERY soon.");
|
||||||
|
/*
|
||||||
|
if (musicPlayers[e.Server].Pause())
|
||||||
|
if (musicPlayers[e.Server].IsPaused)
|
||||||
|
await e.Send("Music player Paused");
|
||||||
|
else
|
||||||
|
await e.Send("Music player unpaused.");
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
|
|
||||||
cgb.CreateCommand("q")
|
cgb.CreateCommand("q")
|
||||||
.Alias("yq")
|
.Alias("yq")
|
||||||
.Description("Queue a song using keywords or link. **You must be in a voice channel**.\n**Usage**: `!m q Dream Of Venice`")
|
.Description("Queue a song using keywords or link. **You must be in a voice channel**.\n**Usage**: `!m q Dream Of Venice`")
|
||||||
.Parameter("Query", ParameterType.Unparsed)
|
.Parameter("query", ParameterType.Unparsed)
|
||||||
.Do(async e => {
|
.Do(async e => {
|
||||||
if (musicPlayers.ContainsKey(e.Server) == false)
|
if (musicPlayers.ContainsKey(e.Server) == false)
|
||||||
musicPlayers.TryAdd(e.Server, new MusicControls());
|
if (musicPlayers.Count > 25) {
|
||||||
|
await e.Send($"{e.User.Mention}, playlist supports up to 25 songs. If you think this is not enough, contact the owner.:warning:");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
musicPlayers.TryAdd(e.Server, new MusicControls());
|
||||||
|
|
||||||
var player = musicPlayers[e.Server];
|
var player = musicPlayers[e.Server];
|
||||||
player.SongQueue.Add(new StreamRequest(NadekoBot.client, e, e.GetArg("Query")));
|
try {
|
||||||
await e.Send(":warning: Music is unstable atm, working on a fix. :warning:");
|
var sr = new StreamRequest(e, e.GetArg("query"));
|
||||||
|
Message msg = null;
|
||||||
|
sr.OnQueued += async() => {
|
||||||
|
msg = await e.Send($":musical_note:**Queued** {sr.Title}");
|
||||||
|
};
|
||||||
|
sr.OnCompleted += async () => {
|
||||||
|
await e.Send($":musical_note:**Finished playing** {sr.Title}");
|
||||||
|
};
|
||||||
|
sr.OnStarted += async () => {
|
||||||
|
if (msg == null)
|
||||||
|
await e.Send($":musical_note:**Started playing** {sr.Title}");
|
||||||
|
else
|
||||||
|
await msg.Edit($":musical_note:**Started playing** {sr.Title}");
|
||||||
|
};
|
||||||
|
sr.OnBuffering += async () => {
|
||||||
|
if (msg != null)
|
||||||
|
msg = await e.Send($":musical_note:**Buffering the song**...{sr.Title}");
|
||||||
|
};
|
||||||
|
player.SongQueue.Add(sr);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Console.WriteLine();
|
||||||
|
await e.Send("Error. :anger:");
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cgb.CreateCommand("lq")
|
cgb.CreateCommand("lq")
|
||||||
@ -173,514 +177,5 @@ namespace NadekoBot.Modules {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
internal static void CleanMusicPlayers() {
|
|
||||||
foreach (var mp in musicPlayers
|
|
||||||
.Where(kvp => kvp.Value.CurrentSong == null
|
|
||||||
&& kvp.Value.SongQueue.Count == 0)) {
|
|
||||||
var val = mp.Value;
|
|
||||||
musicPlayers.TryRemove(mp.Key, out val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string GetMusicStats() {
|
|
||||||
var servers = 0;
|
|
||||||
var queued = 0;
|
|
||||||
musicPlayers.ForEach(kvp => {
|
|
||||||
var mp = kvp.Value;
|
|
||||||
if(mp.SongQueue.Count > 0 || mp.CurrentSong != null)
|
|
||||||
queued += mp.SongQueue.Count + 1;
|
|
||||||
servers++;
|
|
||||||
});
|
|
||||||
|
|
||||||
return $"Playing {queued} songs across {servers} servers.";
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StreamTaskState {
|
|
||||||
Queued,
|
|
||||||
Playing,
|
|
||||||
Completed,
|
|
||||||
NotReady
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
public bool LinkResolved = false;
|
|
||||||
public string StreamUrl;
|
|
||||||
public bool NetworkDone;
|
|
||||||
public long TotalSourceBytes;
|
|
||||||
|
|
||||||
Stream bufferingStream;
|
|
||||||
StreamTask streamTask;
|
|
||||||
|
|
||||||
public StreamTaskState State => streamTask?.State ?? StreamTaskState.Queued;
|
|
||||||
|
|
||||||
|
|
||||||
public StreamRequest(DiscordClient client, CommandEventArgs 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 {
|
|
||||||
ResolveLink();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Console.WriteLine("Exception in ResolveLink: " + ex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void ResolveLink() {
|
|
||||||
var query = RequestText;
|
|
||||||
try {
|
|
||||||
var video = DownloadUrlResolver.GetDownloadUrls(Searches.FindYoutubeUrlByKeywords(query))
|
|
||||||
.Where(v => v.AdaptiveType == AdaptiveType.Audio)
|
|
||||||
.OrderByDescending(v => v.AudioBitrate).FirstOrDefault();
|
|
||||||
|
|
||||||
if (video == null)
|
|
||||||
throw new Exception("Could not load any video elements");
|
|
||||||
|
|
||||||
StreamUrl = video.DownloadUrl;
|
|
||||||
Title = video.Title;
|
|
||||||
string invalidChars = new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars());
|
|
||||||
foreach (char c in invalidChars) {
|
|
||||||
FileName = FileName.Replace(c.ToString(), "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
StartBuffering();
|
|
||||||
LinkResolved = true;
|
|
||||||
Channel.Send($"{User.Mention}, Queued **{video.Title}**");
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Send a message to the guy that queued that
|
|
||||||
Channel.SendMessage("This video is unavailable in the country the Bot is running in, or you enter an invalid name or url.");
|
|
||||||
|
|
||||||
Console.WriteLine("Cannot parse youtube url: " + query);
|
|
||||||
Cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void StartBuffering() {
|
|
||||||
var folder = "StreamBuffers";
|
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
var fullPath = Path.Combine(folder, FileName);
|
|
||||||
|
|
||||||
FileStream fileStream;
|
|
||||||
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(() => {
|
|
||||||
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;
|
|
||||||
fileStream.Write(buffer, 0, read);
|
|
||||||
|
|
||||||
if (TotalSourceBytes > 1024 * 2 && Length.TotalSeconds < 0.1) {
|
|
||||||
Length = GetFileLength(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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();
|
|
||||||
|
|
||||||
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 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) {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = 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 ?? "<no exception>"));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
pcmOutput = p.StandardOutput.BaseStream;
|
|
||||||
input = p.StandardInput.BaseStream;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Cancel() {
|
|
||||||
tokenSource.Cancel();
|
|
||||||
BytesSentToTranscoder = streamRequest.TotalSourceBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public class DualStream : MemoryStream {
|
|
||||||
public long readPos;
|
|
||||||
public long writePos;
|
|
||||||
public override int Read(byte[] buffer, int offset, int count) {
|
|
||||||
int read;
|
|
||||||
lock (this) {
|
|
||||||
Position = readPos;
|
|
||||||
read = base.Read(buffer, offset, count);
|
|
||||||
readPos = Position;
|
|
||||||
}
|
|
||||||
return read;
|
|
||||||
}
|
|
||||||
public override void Write(byte[] buffer, int offset, int count) {
|
|
||||||
lock (this) {
|
|
||||||
Position = writePos;
|
|
||||||
base.Write(buffer, offset, count);
|
|
||||||
writePos = Position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class StreamTask {
|
|
||||||
readonly DiscordClient client;
|
|
||||||
readonly StreamRequest streamRequest;
|
|
||||||
readonly Stream bufferingStream;
|
|
||||||
|
|
||||||
CancellationTokenSource tokenSource;
|
|
||||||
Task audioTask;
|
|
||||||
|
|
||||||
public StreamTaskState State { get; private set; }
|
|
||||||
|
|
||||||
public StreamTask(DiscordClient client, StreamRequest streamRequest, Stream bufferingStream) {
|
|
||||||
this.streamRequest = streamRequest;
|
|
||||||
this.bufferingStream = bufferingStream;
|
|
||||||
this.client = client;
|
|
||||||
|
|
||||||
State = StreamTaskState.Queued;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StartStreaming() {
|
|
||||||
if (State != StreamTaskState.Queued)
|
|
||||||
return;
|
|
||||||
|
|
||||||
State = StreamTaskState.Playing;
|
|
||||||
tokenSource = new CancellationTokenSource();
|
|
||||||
audioTask = Task.Run(StreamFunc, tokenSource.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CancelStreaming() {
|
|
||||||
if (State != StreamTaskState.Queued && State != StreamTaskState.Playing)
|
|
||||||
return;
|
|
||||||
|
|
||||||
tokenSource?.Cancel(false);
|
|
||||||
audioTask?.Wait();
|
|
||||||
State = StreamTaskState.Completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task StreamFunc() {
|
|
||||||
CancellationToken cancellationToken = tokenSource.Token;
|
|
||||||
IAudioClient voiceClient = null;
|
|
||||||
TranscodingTask streamer = null;
|
|
||||||
try {
|
|
||||||
uint byteCounter = 0;
|
|
||||||
|
|
||||||
// Download and read audio from the url
|
|
||||||
streamer = new TranscodingTask(streamRequest, bufferingStream);
|
|
||||||
streamer.Start();
|
|
||||||
|
|
||||||
// Wait until we have at least a few kb transcoded or network stream done
|
|
||||||
while (true) {
|
|
||||||
if (streamRequest.NetworkDone) {
|
|
||||||
await Task.Delay(600);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (streamer.ReadyBytesLeft > 5 * 1024)
|
|
||||||
break;
|
|
||||||
await Task.Delay(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Start streaming to voice
|
|
||||||
await streamRequest.Channel.SendMessage($"Playing **{streamRequest.Title}** [{streamRequest.Length}]");
|
|
||||||
|
|
||||||
var audioService = client.Audio();
|
|
||||||
voiceClient = await audioService.Join(streamRequest.VoiceChannel);
|
|
||||||
|
|
||||||
int blockSize = 1920 * audioService.Config.Channels;
|
|
||||||
byte[] voiceBuffer = new byte[blockSize];
|
|
||||||
var ringBuffer = streamer.PCMOutput;
|
|
||||||
|
|
||||||
Stopwatch timeout = Stopwatch.StartNew();
|
|
||||||
while (true) {
|
|
||||||
var readCount = ringBuffer.Read(voiceBuffer, 0, voiceBuffer.Length);
|
|
||||||
|
|
||||||
if (readCount == 0) {
|
|
||||||
if (timeout.ElapsedMilliseconds > 1500) {
|
|
||||||
Console.WriteLine("Audio stream timed out. Disconnecting.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(200);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
timeout.Restart();
|
|
||||||
|
|
||||||
byteCounter += (uint)voiceBuffer.Length;
|
|
||||||
voiceClient.Send(voiceBuffer, 0, voiceBuffer.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
streamer.Cancel();
|
|
||||||
|
|
||||||
voiceClient.Wait();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
await streamRequest.Channel.SendMessage($":musical_note: {streamRequest.User.Mention} Something went wrong, please report this. :angry: :anger:");
|
|
||||||
Console.WriteLine("Exception while playing music: " + ex);
|
|
||||||
} finally {
|
|
||||||
if (voiceClient != null) {
|
|
||||||
await streamRequest.Channel.SendMessage($"Finished playing **{streamRequest.Title}**");
|
|
||||||
State = StreamTaskState.Completed;
|
|
||||||
streamer?.Cancel();
|
|
||||||
await voiceClient.Disconnect();
|
|
||||||
await Task.Delay(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -140,6 +140,8 @@
|
|||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="Classes\Music\MusicControls.cs" />
|
||||||
|
<Compile Include="Classes\Music\StreamRequest.cs" />
|
||||||
<Compile Include="Classes\SParser.cs" />
|
<Compile Include="Classes\SParser.cs" />
|
||||||
<Compile Include="Commands\ServerGreetCommand.cs" />
|
<Compile Include="Commands\ServerGreetCommand.cs" />
|
||||||
<Compile Include="Commands\SpeedTyping.cs" />
|
<Compile Include="Commands\SpeedTyping.cs" />
|
||||||
|
Loading…
Reference in New Issue
Block a user