Added Music module
This commit is contained in:
parent
fdd13aa087
commit
086b7fd9d7
41 changed files with 4254 additions and 0 deletions
755
src/EllieBot/Modules/Music/Music.cs
Normal file
755
src/EllieBot/Modules/Music/Music.cs
Normal file
|
@ -0,0 +1,755 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Modules.Music.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
[NoPublicBot]
|
||||||
|
public sealed partial class Music : EllieModule<IMusicService>
|
||||||
|
{
|
||||||
|
public enum All { All = -1 }
|
||||||
|
|
||||||
|
public enum InputRepeatType
|
||||||
|
{
|
||||||
|
N = 0, No = 0, None = 0,
|
||||||
|
T = 1, Track = 1, S = 1, Song = 1,
|
||||||
|
Q = 2, Queue = 2, Playlist = 2, Pl = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string MUSIC_ICON_URL = "https://i.imgur.com/nhKS3PT.png";
|
||||||
|
|
||||||
|
private const int LQ_ITEMS_PER_PAGE = 9;
|
||||||
|
|
||||||
|
private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1);
|
||||||
|
private readonly ILogCommandService _logService;
|
||||||
|
|
||||||
|
public Music(ILogCommandService logService)
|
||||||
|
=> _logService = logService;
|
||||||
|
|
||||||
|
private async Task<bool> ValidateAsync()
|
||||||
|
{
|
||||||
|
var user = (IGuildUser)ctx.User;
|
||||||
|
var userVoiceChannelId = user.VoiceChannel?.Id;
|
||||||
|
|
||||||
|
if (userVoiceChannelId is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.must_be_in_voice).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUser = await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
if (currentUser.VoiceChannel?.Id != userVoiceChannelId)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_with_bot_in_voice).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
|
||||||
|
{
|
||||||
|
botUser ??= await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
await _voiceChannelLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
|
||||||
|
await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_voiceChannelLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> QueuePreconditionInternalAsync()
|
||||||
|
{
|
||||||
|
var user = (IGuildUser)ctx.User;
|
||||||
|
var voiceChannelId = user.VoiceChannel?.Id;
|
||||||
|
|
||||||
|
if (voiceChannelId is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.must_be_in_voice).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
var botUser = await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
|
||||||
|
|
||||||
|
if (botUser.VoiceChannel?.Id != voiceChannelId)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_with_bot_in_voice).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task QueueByQuery(string query, bool asNext = false, MusicPlatform? forcePlatform = null)
|
||||||
|
{
|
||||||
|
var succ = await QueuePreconditionInternalAsync();
|
||||||
|
if (!succ)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query, ctx.User.ToString(), asNext, forcePlatform);
|
||||||
|
if (trackInfo is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.track_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL)
|
||||||
|
.WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
|
||||||
|
.WithFooter(trackInfo.Platform.ToString());
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail))
|
||||||
|
embed.WithThumbnailUrl(trackInfo.Thumbnail);
|
||||||
|
|
||||||
|
var queuedMessage = await _service.SendToOutputAsync(ctx.Guild.Id, embed);
|
||||||
|
queuedMessage?.DeleteAfter(10, _logService);
|
||||||
|
if (mp.IsStopped)
|
||||||
|
{
|
||||||
|
var msg = await Response().Pending(strs.queue_stopped(Format.Code(prefix + "play"))).SendAsync();
|
||||||
|
msg.DeleteAfter(10, _logService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MoveToIndex(int index)
|
||||||
|
{
|
||||||
|
if (--index < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await QueuePreconditionInternalAsync();
|
||||||
|
if (!succ)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.MoveTo(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// join vc
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Join()
|
||||||
|
{
|
||||||
|
var user = (IGuildUser)ctx.User;
|
||||||
|
|
||||||
|
var voiceChannelId = user.VoiceChannel?.Id;
|
||||||
|
|
||||||
|
if (voiceChannelId is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.must_be_in_voice).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _service.JoinVoiceChannelAsync(user.GuildId, voiceChannelId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// leave vc (destroy)
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Destroy()
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _service.LeaveVoiceChannelAsync(ctx.Guild.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// play - no args = next
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(2)]
|
||||||
|
public Task Play()
|
||||||
|
=> Next();
|
||||||
|
|
||||||
|
// play - index = skip to that index
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public Task Play(int index)
|
||||||
|
=> MoveToIndex(index);
|
||||||
|
|
||||||
|
// play - query = q(query)
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public Task Play([Leftover] string query)
|
||||||
|
=> QueueByQuery(query);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Queue([Leftover] string query)
|
||||||
|
=> QueueByQuery(query);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task QueueNext([Leftover] string query)
|
||||||
|
=> QueueByQuery(query, true);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Volume(int vol)
|
||||||
|
{
|
||||||
|
if (vol is < 0 or > 100)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.volume_input_invalid).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _service.SetVolumeAsync(ctx.Guild.Id, vol);
|
||||||
|
await Response().Confirm(strs.volume_set(vol)).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Next()
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var success = await _service.PlayAsync(ctx.Guild.Id, ((IGuildUser)ctx.User).VoiceChannel.Id);
|
||||||
|
if (!success)
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// list queue, relevant page
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task ListQueue()
|
||||||
|
{
|
||||||
|
// show page with the current track
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ListQueue((mp.CurrentIndex / LQ_ITEMS_PER_PAGE) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// list queue, specify page
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task ListQueue(int page)
|
||||||
|
{
|
||||||
|
if (--page < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IReadOnlyCollection<IQueuedTrackInfo> tracks;
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || (tracks = mp.GetQueuedTracks()).Count == 0)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmbedBuilder PrintAction(IReadOnlyList<IQueuedTrackInfo> tracks, int curPage)
|
||||||
|
{
|
||||||
|
var desc = string.Empty;
|
||||||
|
var current = mp.GetCurrentTrack(out var currentIndex);
|
||||||
|
if (current is not null)
|
||||||
|
desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc;
|
||||||
|
|
||||||
|
var repeatType = mp.Repeat;
|
||||||
|
var add = string.Empty;
|
||||||
|
if (mp.IsStopped)
|
||||||
|
add += Format.Bold(GetText(strs.queue_stopped(Format.Code(prefix + "play")))) + "\n";
|
||||||
|
// var mps = mp.MaxPlaytimeSeconds;
|
||||||
|
// if (mps > 0)
|
||||||
|
// add += Format.Bold(GetText(strs.song_skips_after(TimeSpan.FromSeconds(mps).ToString("HH\\:mm\\:ss")))) + "\n";
|
||||||
|
if (repeatType == PlayerRepeatType.Track)
|
||||||
|
add += "🔂 " + GetText(strs.repeating_track) + "\n";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (mp.AutoPlay)
|
||||||
|
add += "↪ " + GetText(strs.autoplaying) + "\n";
|
||||||
|
// if (mp.FairPlay && !mp.Autoplay)
|
||||||
|
// add += " " + GetText(strs.fairplay) + "\n";
|
||||||
|
if (repeatType == PlayerRepeatType.Queue)
|
||||||
|
add += "🔁 " + GetText(strs.repeating_queue) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
desc += tracks
|
||||||
|
.Select((v, index) =>
|
||||||
|
{
|
||||||
|
index += LQ_ITEMS_PER_PAGE * curPage;
|
||||||
|
if (index == currentIndex)
|
||||||
|
return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
|
||||||
|
|
||||||
|
return $"`{index + 1}.` {v.PrettyFullName()}";
|
||||||
|
})
|
||||||
|
.Join('\n');
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(add))
|
||||||
|
desc = add + "\n" + desc;
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithAuthor(
|
||||||
|
GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
|
||||||
|
MUSIC_ICON_URL)
|
||||||
|
.WithDescription(desc)
|
||||||
|
.WithFooter(
|
||||||
|
$" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ")
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Paginated()
|
||||||
|
.Items(tracks)
|
||||||
|
.PageSize(LQ_ITEMS_PER_PAGE)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.AddFooter(false)
|
||||||
|
.Page(PrintAction)
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// search
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task QueueSearch([Leftover] string query)
|
||||||
|
{
|
||||||
|
_ = ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
var videos = await _service.SearchVideosAsync(query);
|
||||||
|
|
||||||
|
if (videos.Count == 0)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.track_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var embeds = videos.Select((x, i) => _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithThumbnailUrl(x.Thumbnail)
|
||||||
|
.WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var msg = await Response()
|
||||||
|
.Text(strs.queue_search_results)
|
||||||
|
.Embeds(embeds)
|
||||||
|
.SendAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _));
|
||||||
|
if (input is null || !int.TryParse(input, out var index) || (index -= 1) < 0 || index >= videos.Count)
|
||||||
|
{
|
||||||
|
_logService.AddDeleteIgnore(msg.Id);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await msg.DeleteAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
query = videos[index].Url;
|
||||||
|
|
||||||
|
await Play(query);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_logService.AddDeleteIgnore(msg.Id);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await msg.DeleteAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(1)]
|
||||||
|
public async Task TrackRemove(int index)
|
||||||
|
{
|
||||||
|
if (index < 1)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.removed_track_error).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mp.TryRemoveTrackAt(index - 1, out var track))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.removed_track_error).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL)
|
||||||
|
.WithDescription(track.PrettyName())
|
||||||
|
.WithFooter(track.PrettyInfo())
|
||||||
|
.WithErrorColor();
|
||||||
|
|
||||||
|
await _service.SendToOutputAsync(ctx.Guild.Id, embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Priority(0)]
|
||||||
|
public async Task TrackRemove(All _ = All.All)
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.Clear();
|
||||||
|
await Response().Confirm(strs.queue_cleared).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Stop()
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerRepeatType InputToDbType(InputRepeatType type)
|
||||||
|
=> type switch
|
||||||
|
{
|
||||||
|
InputRepeatType.None => PlayerRepeatType.None,
|
||||||
|
InputRepeatType.Queue => PlayerRepeatType.Queue,
|
||||||
|
InputRepeatType.Track => PlayerRepeatType.Track,
|
||||||
|
_ => PlayerRepeatType.Queue
|
||||||
|
};
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task QueueRepeat(InputRepeatType type = InputRepeatType.Queue)
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _service.SetRepeatAsync(ctx.Guild.Id, InputToDbType(type));
|
||||||
|
|
||||||
|
if (type == InputRepeatType.None)
|
||||||
|
await Response().Confirm(strs.repeating_none).SendAsync();
|
||||||
|
else if (type == InputRepeatType.Queue)
|
||||||
|
await Response().Confirm(strs.repeating_queue).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.repeating_track).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Pause()
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || mp.GetCurrentTrack(out _) is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.TogglePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public Task Radio(string radioLink)
|
||||||
|
=> QueueByQuery(radioLink, false, MusicPlatform.Radio);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
public Task Local([Leftover] string path)
|
||||||
|
=> QueueByQuery(path, false, MusicPlatform.Local);
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task LocalPlaylist([Leftover] string dirPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dirPath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var user = (IGuildUser)ctx.User;
|
||||||
|
var voiceChannelId = user.VoiceChannel?.Id;
|
||||||
|
|
||||||
|
if (voiceChannelId is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.must_be_in_voice).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
var botUser = await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
|
||||||
|
|
||||||
|
if (botUser.VoiceChannel?.Id != voiceChannelId)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_with_bot_in_voice).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _service.EnqueueDirectoryAsync(mp, dirPath, ctx.User.ToString());
|
||||||
|
|
||||||
|
await Response().Confirm(strs.dir_queue_complete).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task TrackMove(int from, int to)
|
||||||
|
{
|
||||||
|
if (--from < 0 || --to < 0 || from == to)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.invalid_input).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = mp.MoveTrack(from, to);
|
||||||
|
if (track is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.invalid_input).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithTitle(track.Title.TrimTo(65))
|
||||||
|
.WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL)
|
||||||
|
.AddField(GetText(strs.from_position), $"#{from + 1}", true)
|
||||||
|
.AddField(GetText(strs.to_position), $"#{to + 1}", true)
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute))
|
||||||
|
embed.WithUrl(track.Url);
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Playlist([Leftover] string playlistQuery)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(playlistQuery))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var succ = await QueuePreconditionInternalAsync();
|
||||||
|
if (!succ)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
|
||||||
|
var queuedCount = await _service.EnqueueYoutubePlaylistAsync(mp, playlistQuery, ctx.User.ToString());
|
||||||
|
if (queuedCount == 0)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_search_results).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.OkAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task NowPlaying()
|
||||||
|
{
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTrack = mp.GetCurrentTrack(out _);
|
||||||
|
if (currentTrack is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL)
|
||||||
|
.WithDescription(currentTrack.PrettyName())
|
||||||
|
.WithThumbnailUrl(currentTrack.Thumbnail)
|
||||||
|
.WithFooter(
|
||||||
|
$"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task PlaylistShuffle()
|
||||||
|
{
|
||||||
|
var valid = await ValidateAsync();
|
||||||
|
if (!valid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.ShuffleQueue();
|
||||||
|
await Response().Confirm(strs.queue_shuffled).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.ManageMessages)]
|
||||||
|
public async Task SetMusicChannel()
|
||||||
|
{
|
||||||
|
await _service.SetMusicChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
|
||||||
|
|
||||||
|
await Response().Confirm(strs.set_music_channel).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.ManageMessages)]
|
||||||
|
public async Task UnsetMusicChannel()
|
||||||
|
{
|
||||||
|
await _service.SetMusicChannelAsync(ctx.Guild.Id, null);
|
||||||
|
|
||||||
|
await Response().Confirm(strs.unset_music_channel).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task AutoDisconnect()
|
||||||
|
{
|
||||||
|
var newState = await _service.ToggleAutoDisconnectAsync(ctx.Guild.Id);
|
||||||
|
|
||||||
|
if (newState)
|
||||||
|
await Response().Confirm(strs.autodc_enable).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.autodc_disable).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task MusicQuality()
|
||||||
|
{
|
||||||
|
var quality = await _service.GetMusicQualityAsync(ctx.Guild.Id);
|
||||||
|
await Response().Confirm(strs.current_music_quality(Format.Bold(quality.ToString()))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[UserPerm(GuildPerm.Administrator)]
|
||||||
|
public async Task MusicQuality(QualityPreset preset)
|
||||||
|
{
|
||||||
|
await _service.SetMusicQualityAsync(ctx.Guild.Id, preset);
|
||||||
|
await Response().Confirm(strs.music_quality_set(Format.Bold(preset.ToString()))).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task QueueAutoPlay()
|
||||||
|
{
|
||||||
|
var newValue = await _service.ToggleQueueAutoPlayAsync(ctx.Guild.Id);
|
||||||
|
if (newValue)
|
||||||
|
await Response().Confirm(strs.music_autoplay_on).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.music_autoplay_off).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task QueueFairplay()
|
||||||
|
{
|
||||||
|
var newValue = await _service.FairplayAsync(ctx.Guild.Id);
|
||||||
|
if (newValue)
|
||||||
|
await Response().Confirm(strs.music_fairplay).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
}
|
||||||
|
}
|
245
src/EllieBot/Modules/Music/PlaylistCommands.cs
Normal file
245
src/EllieBot/Modules/Music/PlaylistCommands.cs
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
#nullable disable
|
||||||
|
using LinqToDB;
|
||||||
|
using EllieBot.Modules.Music.Services;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed partial class Music
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public sealed partial class PlaylistCommands : EllieModule<IMusicService>
|
||||||
|
{
|
||||||
|
private static readonly SemaphoreSlim _playlistLock = new(1, 1);
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly IBotCredentials _creds;
|
||||||
|
|
||||||
|
public PlaylistCommands(DbService db, IBotCredentials creds)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_creds = creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
|
||||||
|
{
|
||||||
|
botUser ??= await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
await _voiceChannelLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
|
||||||
|
await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_voiceChannelLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Playlists([Leftover] int num = 1)
|
||||||
|
{
|
||||||
|
if (num <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<MusicPlaylist> playlists;
|
||||||
|
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
playlists = uow.Set<MusicPlaylist>().GetPlaylistsOnPage(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithAuthor(GetText(strs.playlists_page(num)), MUSIC_ICON_URL)
|
||||||
|
.WithDescription(string.Join("\n",
|
||||||
|
playlists.Select(r => GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count)))))
|
||||||
|
.WithOkColor();
|
||||||
|
|
||||||
|
await Response().Embed(embed).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task DeletePlaylist([Leftover] int id)
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var pl = uow.Set<MusicPlaylist>().FirstOrDefault(x => x.Id == id);
|
||||||
|
|
||||||
|
if (pl is not null)
|
||||||
|
{
|
||||||
|
if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id)
|
||||||
|
{
|
||||||
|
uow.Set<MusicPlaylist>().Remove(pl);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error deleting playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
await Response().Error(strs.playlist_delete_fail).SendAsync();
|
||||||
|
else
|
||||||
|
await Response().Confirm(strs.playlist_deleted).SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task PlaylistShow(int id, int page = 1)
|
||||||
|
{
|
||||||
|
if (page-- < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
MusicPlaylist mpl;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
mpl = uow.Set<MusicPlaylist>().GetWithSongs(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Paginated()
|
||||||
|
.Items(mpl.Songs)
|
||||||
|
.PageSize(20)
|
||||||
|
.CurrentPage(page)
|
||||||
|
.Page((items, _) =>
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
var str = string.Join("\n",
|
||||||
|
items
|
||||||
|
.Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`"));
|
||||||
|
return _sender.CreateEmbed().WithTitle($"\"{mpl.Name}\" by {mpl.Author}")
|
||||||
|
.WithOkColor()
|
||||||
|
.WithDescription(str);
|
||||||
|
})
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Save([Leftover] string name)
|
||||||
|
{
|
||||||
|
if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var songs = mp.GetQueuedTracks()
|
||||||
|
.Select(s => new PlaylistSong
|
||||||
|
{
|
||||||
|
Provider = s.Platform.ToString(),
|
||||||
|
ProviderType = (MusicType)s.Platform,
|
||||||
|
Title = s.Title,
|
||||||
|
Query = s.Url
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
MusicPlaylist playlist;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
playlist = new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Author = ctx.User.Username,
|
||||||
|
AuthorId = ctx.User.Id,
|
||||||
|
Songs = songs.ToList()
|
||||||
|
};
|
||||||
|
uow.Set<MusicPlaylist>().Add(playlist);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Response()
|
||||||
|
.Embed(_sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithTitle(GetText(strs.playlist_saved))
|
||||||
|
.AddField(GetText(strs.name), name)
|
||||||
|
.AddField(GetText(strs.id), playlist.Id.ToString()))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
public async Task Load([Leftover] int id)
|
||||||
|
{
|
||||||
|
// expensive action, 1 at a time
|
||||||
|
await _playlistLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = (IGuildUser)ctx.User;
|
||||||
|
var voiceChannelId = user.VoiceChannel?.Id;
|
||||||
|
|
||||||
|
if (voiceChannelId is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.must_be_in_voice).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.Channel.TriggerTypingAsync();
|
||||||
|
|
||||||
|
var botUser = await ctx.Guild.GetCurrentUserAsync();
|
||||||
|
await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
|
||||||
|
|
||||||
|
if (botUser.VoiceChannel?.Id != voiceChannelId)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.not_with_bot_in_voice).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
|
||||||
|
if (mp is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.no_player).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MusicPlaylist mpl;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
mpl = uow.Set<MusicPlaylist>().GetWithSongs(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mpl is null)
|
||||||
|
{
|
||||||
|
await Response().Error(strs.playlist_id_not_found).SendAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IUserMessage msg = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
msg = await Response()
|
||||||
|
.Pending(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString())))
|
||||||
|
.SendAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
await mp.EnqueueManyAsync(mpl.Songs.Select(x => (x.Query, (MusicPlatform)x.ProviderType)),
|
||||||
|
ctx.User.ToString());
|
||||||
|
|
||||||
|
if (msg is not null)
|
||||||
|
await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_playlistLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Cmd]
|
||||||
|
[OwnerOnly]
|
||||||
|
public async Task DeletePlaylists()
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
await uow.Set<MusicPlaylist>().DeleteAsync();
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
217
src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs
Normal file
217
src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Voice;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
|
||||||
|
public sealed class AyuVoiceStateService : IEService
|
||||||
|
{
|
||||||
|
// public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy);
|
||||||
|
// public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; };
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, IVoiceProxy> _voiceProxies = new();
|
||||||
|
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _voiceGatewayLocks = new();
|
||||||
|
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly MethodInfo _sendVoiceStateUpdateMethodInfo;
|
||||||
|
private readonly object _dnetApiClient;
|
||||||
|
private readonly ulong _currentUserId;
|
||||||
|
|
||||||
|
public AyuVoiceStateService(DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_currentUserId = _client.CurrentUser.Id;
|
||||||
|
|
||||||
|
var prop = _client.GetType()
|
||||||
|
.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||||
|
.First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
|
||||||
|
_dnetApiClient = prop.GetValue(_client, null);
|
||||||
|
_sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType()
|
||||||
|
.GetMethod("SendVoiceStateUpdateAsync",
|
||||||
|
[
|
||||||
|
typeof(ulong), typeof(ulong?), typeof(bool),
|
||||||
|
typeof(bool), typeof(RequestOptions)
|
||||||
|
]);
|
||||||
|
|
||||||
|
_client.LeftGuild += ClientOnLeftGuild;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ClientOnLeftGuild(SocketGuild guild)
|
||||||
|
{
|
||||||
|
if (_voiceProxies.TryRemove(guild.Id, out var proxy))
|
||||||
|
{
|
||||||
|
proxy.StopGateway();
|
||||||
|
proxy.SetGateway(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task InvokeSendVoiceStateUpdateAsync(
|
||||||
|
ulong guildId,
|
||||||
|
ulong? channelId = null,
|
||||||
|
bool isDeafened = false,
|
||||||
|
bool isMuted = false)
|
||||||
|
// return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted);
|
||||||
|
=> (Task)_sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient,
|
||||||
|
[guildId, channelId, isMuted, isDeafened, null]);
|
||||||
|
|
||||||
|
private Task SendLeaveVoiceChannelInternalAsync(ulong guildId)
|
||||||
|
=> InvokeSendVoiceStateUpdateAsync(guildId);
|
||||||
|
|
||||||
|
private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId)
|
||||||
|
=> InvokeSendVoiceStateUpdateAsync(guildId, channelId);
|
||||||
|
|
||||||
|
private SemaphoreSlim GetVoiceGatewayLock(ulong guildId)
|
||||||
|
=> _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1));
|
||||||
|
|
||||||
|
private async Task LeaveVoiceChannelInternalAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
|
||||||
|
{
|
||||||
|
if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId && newState.VoiceChannel?.Id is null)
|
||||||
|
complete.TrySetResult(true);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
|
||||||
|
|
||||||
|
if (_voiceProxies.TryGetValue(guildId, out var proxy))
|
||||||
|
{
|
||||||
|
_ = proxy.StopGateway();
|
||||||
|
proxy.SetGateway(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendLeaveVoiceChannelInternalAsync(guildId);
|
||||||
|
await Task.WhenAny(Task.Delay(1500), complete.Task);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveVoiceChannel(ulong guildId)
|
||||||
|
{
|
||||||
|
var gwLock = GetVoiceGatewayLock(guildId);
|
||||||
|
await gwLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LeaveVoiceChannelInternalAsync(guildId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gwLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IVoiceProxy> InternalConnectToVcAsync(ulong guildId, ulong channelId)
|
||||||
|
{
|
||||||
|
var voiceStateUpdatedSource =
|
||||||
|
new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var voiceServerUpdatedSource =
|
||||||
|
new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
|
||||||
|
{
|
||||||
|
if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId)
|
||||||
|
{
|
||||||
|
if (newState.VoiceChannel?.Id == channelId)
|
||||||
|
voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId);
|
||||||
|
|
||||||
|
voiceStateUpdatedSource.TrySetResult(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnVoiceServerUpdated(SocketVoiceServer data)
|
||||||
|
{
|
||||||
|
if (data.Guild.Id == guildId)
|
||||||
|
voiceServerUpdatedSource.TrySetResult(data);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_client.VoiceServerUpdated += OnVoiceServerUpdated;
|
||||||
|
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
|
||||||
|
|
||||||
|
await SendJoinVoiceChannelInternalAsync(guildId, channelId);
|
||||||
|
|
||||||
|
// create a delay task, how much to wait for gateway response
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var delayTask = Task.Delay(2500, cts.Token);
|
||||||
|
|
||||||
|
// either delay or successful voiceStateUpdate
|
||||||
|
var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task);
|
||||||
|
// either delay or successful voiceServerUpdate
|
||||||
|
var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task);
|
||||||
|
|
||||||
|
// wait for both to end (max 1s) and check if either of them is a delay task
|
||||||
|
var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask);
|
||||||
|
if (results[0] == delayTask || results[1] == delayTask)
|
||||||
|
// if either is delay, return null - connection unsuccessful
|
||||||
|
return null;
|
||||||
|
else
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// if both are succesful, that means we can safely get
|
||||||
|
// the values from completion sources
|
||||||
|
|
||||||
|
var session = await voiceStateUpdatedSource.Task;
|
||||||
|
|
||||||
|
// session can be null. Means we disconnected, or connected to the wrong channel (?!)
|
||||||
|
if (session is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var voiceServerData = await voiceServerUpdatedSource.Task;
|
||||||
|
|
||||||
|
VoiceGateway CreateVoiceGatewayLocal()
|
||||||
|
{
|
||||||
|
return new(guildId, _currentUserId, session, voiceServerData.Token, voiceServerData.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = _voiceProxies.AddOrUpdate(guildId,
|
||||||
|
_ => new VoiceProxy(CreateVoiceGatewayLocal()),
|
||||||
|
(gid, currentProxy) =>
|
||||||
|
{
|
||||||
|
_ = currentProxy.StopGateway();
|
||||||
|
currentProxy.SetGateway(CreateVoiceGatewayLocal());
|
||||||
|
return currentProxy;
|
||||||
|
});
|
||||||
|
|
||||||
|
_ = current.StartGateway(); // don't await, this blocks until gateway is closed
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_client.VoiceServerUpdated -= OnVoiceServerUpdated;
|
||||||
|
_client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IVoiceProxy> JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true)
|
||||||
|
{
|
||||||
|
var gwLock = GetVoiceGatewayLock(guildId);
|
||||||
|
await gwLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LeaveVoiceChannelInternalAsync(guildId);
|
||||||
|
return await InternalConnectToVcAsync(guildId, channelId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gwLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy)
|
||||||
|
=> _voiceProxies.TryGetValue(guildId, out proxy);
|
||||||
|
}
|
36
src/EllieBot/Modules/Music/Services/IMusicService.cs
Normal file
36
src/EllieBot/Modules/Music/Services/IMusicService.cs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
|
||||||
|
public interface IMusicService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Leave voice channel in the specified guild if it's connected to one
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild</param>
|
||||||
|
public Task LeaveVoiceChannelAsync(ulong guildId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins the voice channel with the specified id
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guildId">Id of the guild where the voice channel is</param>
|
||||||
|
/// <param name="voiceChannelId">Id of the voice channel</param>
|
||||||
|
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId);
|
||||||
|
|
||||||
|
Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel);
|
||||||
|
bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer);
|
||||||
|
Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer);
|
||||||
|
Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer);
|
||||||
|
Task<IUserMessage?> SendToOutputAsync(ulong guildId, EmbedBuilder embed);
|
||||||
|
Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId);
|
||||||
|
Task<IList<(string Title, string Url, string Thumbnail)>> SearchVideosAsync(string query);
|
||||||
|
Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId);
|
||||||
|
Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType);
|
||||||
|
Task SetVolumeAsync(ulong guildId, int value);
|
||||||
|
Task<bool> ToggleAutoDisconnectAsync(ulong guildId);
|
||||||
|
Task<QualityPreset> GetMusicQualityAsync(ulong guildId);
|
||||||
|
Task SetMusicQualityAsync(ulong guildId, QualityPreset preset);
|
||||||
|
Task<bool> ToggleQueueAutoPlayAsync(ulong guildId);
|
||||||
|
Task<bool> FairplayAsync(ulong guildId);
|
||||||
|
}
|
438
src/EllieBot/Modules/Music/Services/MusicService.cs
Normal file
438
src/EllieBot/Modules/Music/Services/MusicService.cs
Normal file
|
@ -0,0 +1,438 @@
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
|
||||||
|
public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||||
|
{
|
||||||
|
private readonly AyuVoiceStateService _voiceStateService;
|
||||||
|
private readonly ITrackResolveProvider _trackResolveProvider;
|
||||||
|
private readonly DbService _db;
|
||||||
|
private readonly IYoutubeResolverFactory _ytResolver;
|
||||||
|
private readonly ILocalTrackResolver _localResolver;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IBotStrings _strings;
|
||||||
|
private readonly IGoogleApiService _googleApiService;
|
||||||
|
private readonly YtLoader _ytLoader;
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, IMusicPlayer> _players;
|
||||||
|
private readonly ConcurrentDictionary<ulong, (ITextChannel Default, ITextChannel? Override)> _outputChannels;
|
||||||
|
private readonly ConcurrentDictionary<ulong, MusicPlayerSettings> _settings;
|
||||||
|
|
||||||
|
public MusicService(
|
||||||
|
AyuVoiceStateService voiceStateService,
|
||||||
|
ITrackResolveProvider trackResolveProvider,
|
||||||
|
DbService db,
|
||||||
|
IYoutubeResolverFactory ytResolver,
|
||||||
|
ILocalTrackResolver localResolver,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
IBotStrings strings,
|
||||||
|
IGoogleApiService googleApiService,
|
||||||
|
YtLoader ytLoader,
|
||||||
|
IMessageSenderService sender)
|
||||||
|
{
|
||||||
|
_voiceStateService = voiceStateService;
|
||||||
|
_trackResolveProvider = trackResolveProvider;
|
||||||
|
_db = db;
|
||||||
|
_ytResolver = ytResolver;
|
||||||
|
_localResolver = localResolver;
|
||||||
|
_client = client;
|
||||||
|
_strings = strings;
|
||||||
|
_googleApiService = googleApiService;
|
||||||
|
_ytLoader = ytLoader;
|
||||||
|
_sender = sender;
|
||||||
|
|
||||||
|
_players = new();
|
||||||
|
_outputChannels = new ConcurrentDictionary<ulong, (ITextChannel, ITextChannel?)>();
|
||||||
|
_settings = new();
|
||||||
|
|
||||||
|
_client.LeftGuild += ClientOnLeftGuild;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeMusicPlayer(IMusicPlayer musicPlayer)
|
||||||
|
{
|
||||||
|
musicPlayer.Kill();
|
||||||
|
_ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveMusicPlayer(ulong guildId)
|
||||||
|
{
|
||||||
|
_outputChannels.TryRemove(guildId, out _);
|
||||||
|
if (_players.TryRemove(guildId, out var mp))
|
||||||
|
DisposeMusicPlayer(mp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ClientOnLeftGuild(SocketGuild guild)
|
||||||
|
{
|
||||||
|
RemoveMusicPlayer(guild.Id);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveVoiceChannelAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
RemoveMusicPlayer(guildId);
|
||||||
|
await _voiceStateService.LeaveVoiceChannel(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId)
|
||||||
|
=> _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId);
|
||||||
|
|
||||||
|
public async Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel)
|
||||||
|
{
|
||||||
|
var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel);
|
||||||
|
if (newPLayer is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return _players.GetOrAdd(contextChannel.GuildId, newPLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer)
|
||||||
|
=> _players.TryGetValue(guildId, out musicPlayer);
|
||||||
|
|
||||||
|
public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
await foreach (var track in _ytResolver.GetYoutubeResolver().ResolveTracksFromPlaylistAsync(query))
|
||||||
|
{
|
||||||
|
if (mp.IsKilled)
|
||||||
|
break;
|
||||||
|
|
||||||
|
mp.EnqueueTrack(track, queuer);
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer)
|
||||||
|
{
|
||||||
|
await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath))
|
||||||
|
{
|
||||||
|
if (mp.IsKilled)
|
||||||
|
break;
|
||||||
|
|
||||||
|
mp.EnqueueTrack(track, queuer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IMusicPlayer?> CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel)
|
||||||
|
{
|
||||||
|
var queue = new MusicQueue();
|
||||||
|
var resolver = _trackResolveProvider;
|
||||||
|
|
||||||
|
if (!_voiceStateService.TryGetProxy(guildId, out var proxy))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var settings = await GetSettingsInternalAsync(guildId);
|
||||||
|
|
||||||
|
ITextChannel? overrideChannel = null;
|
||||||
|
if (settings.MusicChannelId is { } channelId)
|
||||||
|
{
|
||||||
|
overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId);
|
||||||
|
|
||||||
|
if (overrideChannel is null)
|
||||||
|
Log.Warning("Saved music output channel doesn't exist, falling back to current channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
_outputChannels[guildId] = (defaultChannel, overrideChannel);
|
||||||
|
|
||||||
|
var mp = new MusicPlayer(queue,
|
||||||
|
resolver,
|
||||||
|
_ytResolver,
|
||||||
|
proxy,
|
||||||
|
_googleApiService,
|
||||||
|
settings.QualityPreset,
|
||||||
|
settings.AutoPlay);
|
||||||
|
|
||||||
|
mp.SetRepeat(settings.PlayerRepeat);
|
||||||
|
|
||||||
|
if (settings.Volume is >= 0 and <= 100)
|
||||||
|
mp.SetVolume(settings.Volume);
|
||||||
|
else
|
||||||
|
Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume);
|
||||||
|
|
||||||
|
mp.OnCompleted += OnTrackCompleted(guildId);
|
||||||
|
mp.OnStarted += OnTrackStarted(guildId);
|
||||||
|
mp.OnQueueStopped += OnQueueStopped(guildId);
|
||||||
|
|
||||||
|
return mp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IUserMessage?> SendToOutputAsync(ulong guildId, EmbedBuilder embed)
|
||||||
|
{
|
||||||
|
if (_outputChannels.TryGetValue(guildId, out var chan))
|
||||||
|
{
|
||||||
|
var msg = await _sender.Response(chan.Override ?? chan.Default)
|
||||||
|
.Embed(embed)
|
||||||
|
.SendAsync();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<IMusicPlayer, IQueuedTrackInfo, Task> OnTrackCompleted(ulong guildId)
|
||||||
|
{
|
||||||
|
IUserMessage? lastFinishedMessage = null;
|
||||||
|
return async (mp, trackInfo) =>
|
||||||
|
{
|
||||||
|
_ = lastFinishedMessage?.DeleteAsync();
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(GetText(guildId, strs.finished_track), Music.MUSIC_ICON_URL)
|
||||||
|
.WithDescription(trackInfo.PrettyName())
|
||||||
|
.WithFooter(trackInfo.PrettyTotalTime());
|
||||||
|
|
||||||
|
lastFinishedMessage = await SendToOutputAsync(guildId, embed);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<IMusicPlayer, IQueuedTrackInfo, int, Task> OnTrackStarted(ulong guildId)
|
||||||
|
{
|
||||||
|
IUserMessage? lastPlayingMessage = null;
|
||||||
|
return async (mp, trackInfo, index) =>
|
||||||
|
{
|
||||||
|
_ = lastPlayingMessage?.DeleteAsync();
|
||||||
|
var embed = _sender.CreateEmbed()
|
||||||
|
.WithOkColor()
|
||||||
|
.WithAuthor(GetText(guildId, strs.playing_track(index + 1)), Music.MUSIC_ICON_URL)
|
||||||
|
.WithDescription(trackInfo.PrettyName())
|
||||||
|
.WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}");
|
||||||
|
|
||||||
|
lastPlayingMessage = await SendToOutputAsync(guildId, embed);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<IMusicPlayer, Task> OnQueueStopped(ulong guildId)
|
||||||
|
=> _ =>
|
||||||
|
{
|
||||||
|
if (_settings.TryGetValue(guildId, out var settings))
|
||||||
|
{
|
||||||
|
if (settings.AutoDisconnect)
|
||||||
|
return LeaveVoiceChannelAsync(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this has to be done because dragging bot to another vc isn't supported yet
|
||||||
|
public async Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId)
|
||||||
|
{
|
||||||
|
if (!TryGetMusicPlayer(guildId, out var mp))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (mp.IsStopped)
|
||||||
|
{
|
||||||
|
if (!_voiceStateService.TryGetProxy(guildId, out var proxy)
|
||||||
|
|| proxy.State == VoiceProxy.VoiceProxyState.Stopped)
|
||||||
|
await JoinVoiceChannelAsync(guildId, voiceChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.Next();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IList<(string Title, string Url, string Thumb)>> SearchYtLoaderVideosAsync(string query)
|
||||||
|
{
|
||||||
|
var result = await _ytLoader.LoadResultsAsync(query);
|
||||||
|
return result.Select(x => (x.Title, x.Url, x.Thumb)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IList<(string Title, string Url, string Thumb)>> SearchGoogleApiVideosAsync(string query)
|
||||||
|
{
|
||||||
|
var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5);
|
||||||
|
return result.Select(x => (x.Name, x.Url, x.Thumbnail)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IList<(string Title, string Url, string Thumbnail)>> SearchVideosAsync(string query)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IList<(string, string, string)> videos = await SearchYtLoaderVideosAsync(query);
|
||||||
|
if (videos.Count > 0)
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await SearchGoogleApiVideosAsync(query);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Failed getting video results with Google Api. "
|
||||||
|
+ "Probably google api key missing: {ErrorMessage}",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<(string, string, string)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetText(ulong guildId, LocStr str)
|
||||||
|
=> _strings.GetText(str, guildId);
|
||||||
|
|
||||||
|
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders()
|
||||||
|
{
|
||||||
|
// random track that's playing
|
||||||
|
yield return ("%music.playing%", () =>
|
||||||
|
{
|
||||||
|
var randomPlayingTrack = _players.Select(x => x.Value.GetCurrentTrack(out _))
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Shuffle()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (randomPlayingTrack is null)
|
||||||
|
return "-";
|
||||||
|
|
||||||
|
return randomPlayingTrack.Title;
|
||||||
|
});
|
||||||
|
|
||||||
|
// number of servers currently listening to music
|
||||||
|
yield return ("%music.servers%", () =>
|
||||||
|
{
|
||||||
|
var count = _players.Select(x => x.Value.GetCurrentTrack(out _)).Count(x => x is not null);
|
||||||
|
|
||||||
|
return count.ToString();
|
||||||
|
});
|
||||||
|
|
||||||
|
yield return ("%music.queued%", () =>
|
||||||
|
{
|
||||||
|
var count = _players.Sum(x => x.Value.GetQueuedTracks().Count);
|
||||||
|
|
||||||
|
return count.ToString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Settings
|
||||||
|
|
||||||
|
private async Task<MusicPlayerSettings> GetSettingsInternalAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
if (_settings.TryGetValue(guildId, out var settings))
|
||||||
|
return settings;
|
||||||
|
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var toReturn = _settings[guildId] = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ModifySettingsInternalAsync<TState>(
|
||||||
|
ulong guildId,
|
||||||
|
Action<MusicPlayerSettings, TState> action,
|
||||||
|
TState state)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var ms = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId);
|
||||||
|
action(ms, state);
|
||||||
|
await uow.SaveChangesAsync();
|
||||||
|
_settings[guildId] = ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId)
|
||||||
|
{
|
||||||
|
if (channelId is null)
|
||||||
|
{
|
||||||
|
await UnsetMusicChannelAsync(guildId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value);
|
||||||
|
if (channel is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, chId) => { settings.MusicChannelId = chId; },
|
||||||
|
channelId);
|
||||||
|
|
||||||
|
_outputChannels.AddOrUpdate(guildId, (channel, channel), (_, old) => (old.Default, channel));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnsetMusicChannelAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, _) => { settings.MusicChannelId = null; },
|
||||||
|
(ulong?)null);
|
||||||
|
|
||||||
|
if (_outputChannels.TryGetValue(guildId, out var old))
|
||||||
|
_outputChannels[guildId] = (old.Default, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType)
|
||||||
|
{
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, type) => { settings.PlayerRepeat = type; },
|
||||||
|
repeatType);
|
||||||
|
|
||||||
|
if (TryGetMusicPlayer(guildId, out var mp))
|
||||||
|
mp.SetRepeat(repeatType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetVolumeAsync(ulong guildId, int value)
|
||||||
|
{
|
||||||
|
if (value is < 0 or > 100)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value));
|
||||||
|
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, newValue) => { settings.Volume = newValue; },
|
||||||
|
value);
|
||||||
|
|
||||||
|
if (TryGetMusicPlayer(guildId, out var mp))
|
||||||
|
mp.SetVolume(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ToggleAutoDisconnectAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
var newState = false;
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, _) => { newState = settings.AutoDisconnect = !settings.AutoDisconnect; },
|
||||||
|
default(object));
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QualityPreset> GetMusicQualityAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
await using var uow = _db.GetDbContext();
|
||||||
|
var settings = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId);
|
||||||
|
return settings.QualityPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset)
|
||||||
|
=> ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, _) => { settings.QualityPreset = preset; },
|
||||||
|
preset);
|
||||||
|
|
||||||
|
public async Task<bool> ToggleQueueAutoPlayAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
var newValue = false;
|
||||||
|
await ModifySettingsInternalAsync(guildId,
|
||||||
|
(settings, _) => newValue = settings.AutoPlay = !settings.AutoPlay,
|
||||||
|
false);
|
||||||
|
|
||||||
|
if (TryGetMusicPlayer(guildId, out var mp))
|
||||||
|
mp.AutoPlay = newValue;
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> FairplayAsync(ulong guildId)
|
||||||
|
{
|
||||||
|
if (TryGetMusicPlayer(guildId, out var mp))
|
||||||
|
{
|
||||||
|
mp.SetFairplay();
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
74
src/EllieBot/Modules/Music/Services/extractor/Misc.cs
Normal file
74
src/EllieBot/Modules/Music/Services/extractor/Misc.cs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
|
||||||
|
public sealed partial class YtLoader
|
||||||
|
{
|
||||||
|
public class InitRange
|
||||||
|
{
|
||||||
|
public string Start { get; set; }
|
||||||
|
public string End { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IndexRange
|
||||||
|
{
|
||||||
|
public string Start { get; set; }
|
||||||
|
public string End { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ColorInfo
|
||||||
|
{
|
||||||
|
public string Primaries { get; set; }
|
||||||
|
public string TransferCharacteristics { get; set; }
|
||||||
|
public string MatrixCoefficients { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class YtAdaptiveFormat
|
||||||
|
{
|
||||||
|
public int Itag { get; set; }
|
||||||
|
public string MimeType { get; set; }
|
||||||
|
public int Bitrate { get; set; }
|
||||||
|
public int Width { get; set; }
|
||||||
|
public int Height { get; set; }
|
||||||
|
public InitRange InitRange { get; set; }
|
||||||
|
public IndexRange IndexRange { get; set; }
|
||||||
|
public string LastModified { get; set; }
|
||||||
|
public string ContentLength { get; set; }
|
||||||
|
public string Quality { get; set; }
|
||||||
|
public int Fps { get; set; }
|
||||||
|
public string QualityLabel { get; set; }
|
||||||
|
public string ProjectionType { get; set; }
|
||||||
|
public int AverageBitrate { get; set; }
|
||||||
|
public ColorInfo ColorInfo { get; set; }
|
||||||
|
public string ApproxDurationMs { get; set; }
|
||||||
|
public string SignatureCipher { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class TrackInfo
|
||||||
|
{
|
||||||
|
public abstract string Url { get; }
|
||||||
|
public abstract string Title { get; }
|
||||||
|
public abstract string Thumb { get; }
|
||||||
|
public abstract TimeSpan Duration { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class YtTrackInfo : TrackInfo
|
||||||
|
{
|
||||||
|
private const string BASE_YOUTUBE_URL = "https://youtube.com/watch?v=";
|
||||||
|
public override string Url { get; }
|
||||||
|
public override string Title { get; }
|
||||||
|
public override string Thumb { get; }
|
||||||
|
public override TimeSpan Duration { get; }
|
||||||
|
|
||||||
|
private readonly string _videoId;
|
||||||
|
|
||||||
|
public YtTrackInfo(string title, string videoId, string thumb, TimeSpan duration)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Thumb = thumb;
|
||||||
|
Url = BASE_YOUTUBE_URL + videoId;
|
||||||
|
Duration = duration;
|
||||||
|
|
||||||
|
_videoId = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
130
src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs
Normal file
130
src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Services;
|
||||||
|
|
||||||
|
public sealed partial class YtLoader : IEService
|
||||||
|
{
|
||||||
|
private static readonly byte[] _ytResultInitialData = Encoding.UTF8.GetBytes("var ytInitialData = ");
|
||||||
|
private static readonly byte[] _ytResultJsonEnd = Encoding.UTF8.GetBytes(";<");
|
||||||
|
|
||||||
|
private static readonly string[] _durationFormats =
|
||||||
|
[
|
||||||
|
@"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss"
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
|
||||||
|
public YtLoader(IHttpClientFactory httpFactory)
|
||||||
|
=> _httpFactory = httpFactory;
|
||||||
|
|
||||||
|
// public async Task<TrackInfo> LoadTrackByIdAsync(string videoId)
|
||||||
|
// {
|
||||||
|
// using var http = new HttpClient();
|
||||||
|
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1");
|
||||||
|
// http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00");
|
||||||
|
// http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
|
||||||
|
//
|
||||||
|
// var responseString = await http.GetStringAsync($"https://youtube.com?" +
|
||||||
|
// $"pbj=1" +
|
||||||
|
// $"&hl=en" +
|
||||||
|
// $"&v=" + videoId);
|
||||||
|
//
|
||||||
|
// var jsonDoc = JsonDocument.Parse(responseString).RootElement;
|
||||||
|
// var elem = jsonDoc.EnumerateArray()
|
||||||
|
// .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch");
|
||||||
|
//
|
||||||
|
// var formatsJsonArray = elem.GetProperty("streamingdata")
|
||||||
|
// .GetProperty("formats")
|
||||||
|
// .GetRawText();
|
||||||
|
//
|
||||||
|
// var formats = JsonSerializer.Deserialize<List<YtAdaptiveFormat>>(formatsJsonArray);
|
||||||
|
// var result = formats
|
||||||
|
// .Where(x => x.MimeType.StartsWith("audio/"))
|
||||||
|
// .OrderByDescending(x => x.Bitrate)
|
||||||
|
// .FirstOrDefault();
|
||||||
|
//
|
||||||
|
// if (result is null)
|
||||||
|
// return null;
|
||||||
|
//
|
||||||
|
// return new YtTrackInfo("1", "2", TimeSpan.Zero);
|
||||||
|
// }
|
||||||
|
|
||||||
|
public async Task<IList<TrackInfo>> LoadResultsAsync(string query)
|
||||||
|
{
|
||||||
|
query = Uri.EscapeDataString(query);
|
||||||
|
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
|
||||||
|
|
||||||
|
byte[] response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// there is a lot of useless html above the script tag, however if html gets significantly reduced
|
||||||
|
// this will result in the json being cut off
|
||||||
|
|
||||||
|
var mem = GetScriptResponseSpan(response);
|
||||||
|
var root = JsonDocument.Parse(mem).RootElement;
|
||||||
|
|
||||||
|
using var tracksJsonItems = root
|
||||||
|
.GetProperty("contents")
|
||||||
|
.GetProperty("twoColumnSearchResultsRenderer")
|
||||||
|
.GetProperty("primaryContents")
|
||||||
|
.GetProperty("sectionListRenderer")
|
||||||
|
.GetProperty("contents")[0]
|
||||||
|
.GetProperty("itemSectionRenderer")
|
||||||
|
.GetProperty("contents")
|
||||||
|
.EnumerateArray();
|
||||||
|
|
||||||
|
var tracks = new List<TrackInfo>();
|
||||||
|
foreach (var track in tracksJsonItems)
|
||||||
|
{
|
||||||
|
if (!track.TryGetProperty("videoRenderer", out var elem))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var videoId = elem.GetProperty("videoId").GetString();
|
||||||
|
var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString();
|
||||||
|
var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString();
|
||||||
|
var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString();
|
||||||
|
|
||||||
|
if (!TimeSpan.TryParseExact(durationString,
|
||||||
|
_durationFormats,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var duration))
|
||||||
|
{
|
||||||
|
Log.Warning("Cannot parse duration: {DurationString}", durationString);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.Add(new YtTrackInfo(title, videoId, thumb, duration));
|
||||||
|
if (tracks.Count >= 5)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Memory<byte> GetScriptResponseSpan(byte[] response)
|
||||||
|
{
|
||||||
|
var responseSpan = response.AsSpan()[140_000..];
|
||||||
|
var startIndex = responseSpan.IndexOf(_ytResultInitialData);
|
||||||
|
if (startIndex == -1)
|
||||||
|
return null; // FUTURE try selecting html
|
||||||
|
startIndex += _ytResultInitialData.Length;
|
||||||
|
|
||||||
|
var endIndex =
|
||||||
|
140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(_ytResultJsonEnd) + 20_000;
|
||||||
|
startIndex += 140_000;
|
||||||
|
return response.AsMemory(startIndex, endIndex - startIndex);
|
||||||
|
}
|
||||||
|
}
|
12
src/EllieBot/Modules/Music/_common/ICachableTrackData.cs
Normal file
12
src/EllieBot/Modules/Music/_common/ICachableTrackData.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface ICachableTrackData
|
||||||
|
{
|
||||||
|
string Id { get; set; }
|
||||||
|
string Url { get; set; }
|
||||||
|
string Thumbnail { get; set; }
|
||||||
|
public TimeSpan Duration { get; }
|
||||||
|
MusicPlatform Platform { get; set; }
|
||||||
|
string Title { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface ILocalTrackResolver : IPlatformQueryResolver
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
|
||||||
|
}
|
41
src/EllieBot/Modules/Music/_common/IMusicPlayer.cs
Normal file
41
src/EllieBot/Modules/Music/_common/IMusicPlayer.cs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IMusicPlayer : IDisposable
|
||||||
|
{
|
||||||
|
float Volume { get; }
|
||||||
|
bool IsPaused { get; }
|
||||||
|
bool IsStopped { get; }
|
||||||
|
bool IsKilled { get; }
|
||||||
|
int CurrentIndex { get; }
|
||||||
|
public PlayerRepeatType Repeat { get; }
|
||||||
|
bool AutoPlay { get; set; }
|
||||||
|
|
||||||
|
void Stop();
|
||||||
|
void Clear();
|
||||||
|
IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks();
|
||||||
|
IQueuedTrackInfo? GetCurrentTrack(out int index);
|
||||||
|
void Next();
|
||||||
|
bool MoveTo(int index);
|
||||||
|
void SetVolume(int newVolume);
|
||||||
|
|
||||||
|
void Kill();
|
||||||
|
bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo);
|
||||||
|
|
||||||
|
|
||||||
|
Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
|
||||||
|
string query,
|
||||||
|
string queuer,
|
||||||
|
bool asNext,
|
||||||
|
MusicPlatform? forcePlatform = null);
|
||||||
|
|
||||||
|
Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer);
|
||||||
|
bool TogglePause();
|
||||||
|
IQueuedTrackInfo? MoveTrack(int from, int to);
|
||||||
|
void EnqueueTrack(ITrackInfo track, string queuer);
|
||||||
|
void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer);
|
||||||
|
void SetRepeat(PlayerRepeatType type);
|
||||||
|
void ShuffleQueue();
|
||||||
|
void SetFairplay();
|
||||||
|
}
|
23
src/EllieBot/Modules/Music/_common/IMusicQueue.cs
Normal file
23
src/EllieBot/Modules/Music/_common/IMusicQueue.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IMusicQueue
|
||||||
|
{
|
||||||
|
int Index { get; }
|
||||||
|
int Count { get; }
|
||||||
|
IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index);
|
||||||
|
IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index);
|
||||||
|
|
||||||
|
void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<IQueuedTrackInfo> List();
|
||||||
|
IQueuedTrackInfo? GetCurrent(out int index);
|
||||||
|
void Advance();
|
||||||
|
void Clear();
|
||||||
|
bool SetIndex(int index);
|
||||||
|
bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent);
|
||||||
|
void RemoveCurrent();
|
||||||
|
IQueuedTrackInfo? MoveTrack(int from, int to);
|
||||||
|
void Shuffle(Random rng);
|
||||||
|
bool IsLast();
|
||||||
|
void ReorderFairly();
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IPlatformQueryResolver
|
||||||
|
{
|
||||||
|
Task<ITrackInfo?> ResolveByQueryAsync(string query);
|
||||||
|
}
|
9
src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs
Normal file
9
src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IQueuedTrackInfo : ITrackInfo
|
||||||
|
{
|
||||||
|
public ITrackInfo TrackInfo { get; }
|
||||||
|
|
||||||
|
public string Queuer { get; }
|
||||||
|
}
|
6
src/EllieBot/Modules/Music/_common/IRadioResolver.cs
Normal file
6
src/EllieBot/Modules/Music/_common/IRadioResolver.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IRadioResolver : IPlatformQueryResolver
|
||||||
|
{
|
||||||
|
}
|
25
src/EllieBot/Modules/Music/_common/ITrackCacher.cs
Normal file
25
src/EllieBot/Modules/Music/_common/ITrackCacher.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface ITrackCacher
|
||||||
|
{
|
||||||
|
Task<string?> GetOrCreateStreamLink(
|
||||||
|
string id,
|
||||||
|
MusicPlatform platform,
|
||||||
|
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory);
|
||||||
|
|
||||||
|
Task CacheTrackDataAsync(ICachableTrackData data);
|
||||||
|
Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform);
|
||||||
|
Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform);
|
||||||
|
Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data);
|
||||||
|
|
||||||
|
Task CacheStreamUrlAsync(
|
||||||
|
string id,
|
||||||
|
MusicPlatform platform,
|
||||||
|
string url,
|
||||||
|
TimeSpan expiry);
|
||||||
|
|
||||||
|
Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform);
|
||||||
|
Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids);
|
||||||
|
Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId);
|
||||||
|
Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform);
|
||||||
|
}
|
11
src/EllieBot/Modules/Music/_common/ITrackInfo.cs
Normal file
11
src/EllieBot/Modules/Music/_common/ITrackInfo.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface ITrackInfo
|
||||||
|
{
|
||||||
|
public string Id => string.Empty;
|
||||||
|
public string Title { get; }
|
||||||
|
public string Url { get; }
|
||||||
|
public string Thumbnail { get; }
|
||||||
|
public TimeSpan Duration { get; }
|
||||||
|
public MusicPlatform Platform { get; }
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface ITrackResolveProvider
|
||||||
|
{
|
||||||
|
Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
|
||||||
|
}
|
15
src/EllieBot/Modules/Music/_common/IVoiceProxy.cs
Normal file
15
src/EllieBot/Modules/Music/_common/IVoiceProxy.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Voice;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IVoiceProxy
|
||||||
|
{
|
||||||
|
VoiceProxy.VoiceProxyState State { get; }
|
||||||
|
public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length);
|
||||||
|
public void SetGateway(VoiceGateway gateway);
|
||||||
|
Task StartSpeakingAsync();
|
||||||
|
Task StopSpeakingAsync();
|
||||||
|
public Task StartGateway();
|
||||||
|
Task StopGateway();
|
||||||
|
}
|
11
src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs
Normal file
11
src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public interface IYoutubeResolver : IPlatformQueryResolver
|
||||||
|
{
|
||||||
|
public Task<ITrackInfo?> ResolveByIdAsync(string id);
|
||||||
|
IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
|
||||||
|
Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
|
||||||
|
Task<string?> GetStreamUrl(string query);
|
||||||
|
}
|
19
src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs
Normal file
19
src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class CachableTrackData : ICachableTrackData
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
public string Thumbnail { get; set; } = string.Empty;
|
||||||
|
public double TotalDurationMs { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public TimeSpan Duration
|
||||||
|
=> TimeSpan.FromMilliseconds(TotalDurationMs);
|
||||||
|
|
||||||
|
public MusicPlatform Platform { get; set; }
|
||||||
|
}
|
95
src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs
Normal file
95
src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Common;
|
||||||
|
|
||||||
|
public sealed class MultimediaTimer : IDisposable
|
||||||
|
{
|
||||||
|
private LpTimeProcDelegate lpTimeProc;
|
||||||
|
private readonly uint _eventId;
|
||||||
|
private readonly Action<object> _callback;
|
||||||
|
private readonly object _state;
|
||||||
|
|
||||||
|
public MultimediaTimer(Action<object> callback, object state, int period)
|
||||||
|
{
|
||||||
|
if (period <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0");
|
||||||
|
|
||||||
|
_callback = callback;
|
||||||
|
_state = state;
|
||||||
|
|
||||||
|
lpTimeProc = CallbackInternal;
|
||||||
|
_eventId = timeSetEvent((uint)period, 1, lpTimeProc, 0, TimerMode.Periodic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The timeSetEvent function starts a specified timer event. The multimedia timer runs in its own thread.
|
||||||
|
/// After the event is activated, it calls the specified callback function or sets or pulses the specified
|
||||||
|
/// event object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uDelay">
|
||||||
|
/// Event delay, in milliseconds. If this value is not in the range of the minimum and
|
||||||
|
/// maximum event delays supported by the timer, the function returns an error.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="uResolution">
|
||||||
|
/// Resolution of the timer event, in milliseconds. The resolution increases with
|
||||||
|
/// smaller values; a resolution of 0 indicates periodic events should occur with the greatest possible accuracy.
|
||||||
|
/// To reduce system overhead, however, you should use the maximum value appropriate for your application.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="lpTimeProc">
|
||||||
|
/// Pointer to a callback function that is called once upon expiration of a single event or periodically upon
|
||||||
|
/// expiration of periodic events. If fuEvent specifies the TIME_CALLBACK_EVENT_SET or TIME_CALLBACK_EVENT_PULSE
|
||||||
|
/// flag, then the lpTimeProc parameter is interpreted as a handle to an event object. The event will be set or
|
||||||
|
/// pulsed upon completion of a single event or periodically upon completion of periodic events.
|
||||||
|
/// For any other value of fuEvent, the lpTimeProc parameter is a pointer to a callback function of type
|
||||||
|
/// LPTIMECALLBACK.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="dwUser">User-supplied callback data.</param>
|
||||||
|
/// <param name="fuEvent"></param>
|
||||||
|
/// <returns>Timer event type. This parameter may include one of the following values.</returns>
|
||||||
|
[DllImport("Winmm.dll")]
|
||||||
|
private static extern uint timeSetEvent(
|
||||||
|
uint uDelay,
|
||||||
|
uint uResolution,
|
||||||
|
LpTimeProcDelegate lpTimeProc,
|
||||||
|
int dwUser,
|
||||||
|
TimerMode fuEvent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The timeKillEvent function cancels a specified timer event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uTimerId">
|
||||||
|
/// Identifier of the timer event to cancel.
|
||||||
|
/// This identifier was returned by the timeSetEvent function when the timer event was set up.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist.</returns>
|
||||||
|
[DllImport("Winmm.dll")]
|
||||||
|
private static extern int timeKillEvent(uint uTimerId);
|
||||||
|
|
||||||
|
private void CallbackInternal(
|
||||||
|
uint uTimerId,
|
||||||
|
uint uMsg,
|
||||||
|
int dwUser,
|
||||||
|
int dw1,
|
||||||
|
int dw2)
|
||||||
|
=> _callback(_state);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lpTimeProc = default;
|
||||||
|
timeKillEvent(_eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate void LpTimeProcDelegate(
|
||||||
|
uint uTimerId,
|
||||||
|
uint uMsg,
|
||||||
|
int dwUser,
|
||||||
|
int dw1,
|
||||||
|
int dw2);
|
||||||
|
|
||||||
|
private enum TimerMode
|
||||||
|
{
|
||||||
|
OneShot,
|
||||||
|
Periodic
|
||||||
|
}
|
||||||
|
}
|
57
src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs
Normal file
57
src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public static class MusicExtensions
|
||||||
|
{
|
||||||
|
public static string PrettyTotalTime(this IMusicPlayer mp)
|
||||||
|
{
|
||||||
|
long sum = 0;
|
||||||
|
foreach (var track in mp.GetQueuedTracks())
|
||||||
|
{
|
||||||
|
if (track.Duration == TimeSpan.MaxValue)
|
||||||
|
return "∞";
|
||||||
|
|
||||||
|
sum += track.Duration.Ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = new TimeSpan(sum);
|
||||||
|
|
||||||
|
return total.ToString(@"hh\:mm\:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string PrettyVolume(this IMusicPlayer mp)
|
||||||
|
=> $"🔉 {(int)(mp.Volume * 100)}%";
|
||||||
|
|
||||||
|
public static string PrettyName(this ITrackInfo trackInfo)
|
||||||
|
=> $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**";
|
||||||
|
|
||||||
|
public static string PrettyInfo(this IQueuedTrackInfo trackInfo)
|
||||||
|
=> $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}";
|
||||||
|
|
||||||
|
public static string PrettyFullName(this IQueuedTrackInfo trackInfo)
|
||||||
|
=> $@"{trackInfo.PrettyName()}
|
||||||
|
`{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {Format.Sanitize(trackInfo.Queuer.TrimTo(15))}`";
|
||||||
|
|
||||||
|
public static string PrettyTotalTime(this ITrackInfo trackInfo)
|
||||||
|
{
|
||||||
|
if (trackInfo.Duration == TimeSpan.Zero)
|
||||||
|
return "(?)";
|
||||||
|
if (trackInfo.Duration == TimeSpan.MaxValue)
|
||||||
|
return "∞";
|
||||||
|
if (trackInfo.Duration.TotalHours >= 1)
|
||||||
|
return trackInfo.Duration.ToString("""hh\:mm\:ss""");
|
||||||
|
|
||||||
|
return trackInfo.Duration.ToString("""mm\:ss""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id)
|
||||||
|
=> new CachableTrackData
|
||||||
|
{
|
||||||
|
TotalDurationMs = trackInfo.Duration.TotalMilliseconds,
|
||||||
|
Id = id,
|
||||||
|
Thumbnail = trackInfo.Thumbnail,
|
||||||
|
Url = trackInfo.Url,
|
||||||
|
Platform = trackInfo.Platform,
|
||||||
|
Title = trackInfo.Title
|
||||||
|
};
|
||||||
|
}
|
9
src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs
Normal file
9
src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public enum MusicPlatform
|
||||||
|
{
|
||||||
|
Radio,
|
||||||
|
Youtube,
|
||||||
|
Local,
|
||||||
|
}
|
545
src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs
Normal file
545
src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs
Normal file
|
@ -0,0 +1,545 @@
|
||||||
|
using EllieBot.Voice;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class MusicPlayer : IMusicPlayer
|
||||||
|
{
|
||||||
|
public event Func<IMusicPlayer, IQueuedTrackInfo, Task>? OnCompleted;
|
||||||
|
public event Func<IMusicPlayer, IQueuedTrackInfo, int, Task>? OnStarted;
|
||||||
|
public event Func<IMusicPlayer, Task>? OnQueueStopped;
|
||||||
|
public bool IsKilled { get; private set; }
|
||||||
|
public bool IsStopped { get; private set; }
|
||||||
|
public bool IsPaused { get; private set; }
|
||||||
|
public PlayerRepeatType Repeat { get; private set; }
|
||||||
|
|
||||||
|
public int CurrentIndex
|
||||||
|
=> _queue.Index;
|
||||||
|
|
||||||
|
public float Volume { get; private set; } = 1.0f;
|
||||||
|
|
||||||
|
private readonly AdjustVolumeDelegate _adjustVolume;
|
||||||
|
private readonly VoiceClient _vc;
|
||||||
|
|
||||||
|
private readonly IMusicQueue _queue;
|
||||||
|
private readonly ITrackResolveProvider _trackResolveProvider;
|
||||||
|
private readonly IYoutubeResolverFactory _ytResolverFactory;
|
||||||
|
private readonly IVoiceProxy _proxy;
|
||||||
|
private readonly IGoogleApiService _googleApiService;
|
||||||
|
private readonly ISongBuffer _songBuffer;
|
||||||
|
|
||||||
|
private bool skipped;
|
||||||
|
private int? forceIndex;
|
||||||
|
private readonly Thread _thread;
|
||||||
|
private readonly Random _rng;
|
||||||
|
|
||||||
|
public bool AutoPlay { get; set; }
|
||||||
|
|
||||||
|
public MusicPlayer(
|
||||||
|
IMusicQueue queue,
|
||||||
|
ITrackResolveProvider trackResolveProvider,
|
||||||
|
IYoutubeResolverFactory ytResolverFactory,
|
||||||
|
IVoiceProxy proxy,
|
||||||
|
IGoogleApiService googleApiService,
|
||||||
|
QualityPreset qualityPreset,
|
||||||
|
bool autoPlay)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_trackResolveProvider = trackResolveProvider;
|
||||||
|
_ytResolverFactory = ytResolverFactory;
|
||||||
|
_proxy = proxy;
|
||||||
|
_googleApiService = googleApiService;
|
||||||
|
AutoPlay = autoPlay;
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
|
||||||
|
_vc = GetVoiceClient(qualityPreset);
|
||||||
|
if (_vc.BitDepth == 16)
|
||||||
|
_adjustVolume = AdjustVolumeInt16;
|
||||||
|
else
|
||||||
|
_adjustVolume = AdjustVolumeFloat32;
|
||||||
|
|
||||||
|
_songBuffer = new PoopyBufferImmortalized(_vc.InputLength);
|
||||||
|
|
||||||
|
_thread = new(async () => { await PlayLoop(); });
|
||||||
|
_thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VoiceClient GetVoiceClient(QualityPreset qualityPreset)
|
||||||
|
=> qualityPreset switch
|
||||||
|
{
|
||||||
|
QualityPreset.Highest => new(),
|
||||||
|
QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40),
|
||||||
|
QualityPreset.Medium => new(SampleRate._48k,
|
||||||
|
Bitrate._96k,
|
||||||
|
Channels.Two,
|
||||||
|
FrameDelay.Delay40,
|
||||||
|
BitDepthEnum.UInt16),
|
||||||
|
QualityPreset.Low => new(SampleRate._48k,
|
||||||
|
Bitrate._64k,
|
||||||
|
Channels.Two,
|
||||||
|
FrameDelay.Delay40,
|
||||||
|
BitDepthEnum.UInt16),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task PlayLoop()
|
||||||
|
{
|
||||||
|
var sw = new Stopwatch();
|
||||||
|
|
||||||
|
while (!IsKilled)
|
||||||
|
{
|
||||||
|
// wait until a song is available in the queue
|
||||||
|
// or until the queue is resumed
|
||||||
|
var track = _queue.GetCurrent(out var index);
|
||||||
|
|
||||||
|
if (track is null || IsStopped)
|
||||||
|
{
|
||||||
|
await Task.Delay(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped)
|
||||||
|
{
|
||||||
|
skipped = false;
|
||||||
|
_queue.Advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
var token = cancellationTokenSource.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// light up green in vc
|
||||||
|
_ = _proxy.StartSpeakingAsync();
|
||||||
|
|
||||||
|
_ = OnStarted?.Invoke(this, track, index);
|
||||||
|
|
||||||
|
// make sure song buffer is ready to be (re)used
|
||||||
|
_songBuffer.Reset();
|
||||||
|
|
||||||
|
var streamUrl = await GetStreamUrl(track);
|
||||||
|
// start up the data source
|
||||||
|
using var source = FfmpegTrackDataSource.CreateAsync(
|
||||||
|
_vc.BitDepth,
|
||||||
|
streamUrl,
|
||||||
|
track.Platform == MusicPlatform.Local);
|
||||||
|
|
||||||
|
// start moving data from the source into the buffer
|
||||||
|
// this method will return once the sufficient prebuffering is done
|
||||||
|
await _songBuffer.BufferAsync(source, token);
|
||||||
|
|
||||||
|
// // Implemenation with multimedia timer. Works but a hassle because no support for switching
|
||||||
|
// // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option
|
||||||
|
// // for selfhosters.
|
||||||
|
// if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
// {
|
||||||
|
// var cancelSource = new CancellationTokenSource();
|
||||||
|
// var cancelToken = cancelSource.Token;
|
||||||
|
// using var timer = new MultimediaTimer(_ =>
|
||||||
|
// {
|
||||||
|
// if (IsStopped || IsKilled)
|
||||||
|
// {
|
||||||
|
// cancelSource.Cancel();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (_skipped)
|
||||||
|
// {
|
||||||
|
// _skipped = false;
|
||||||
|
// cancelSource.Cancel();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (IsPaused)
|
||||||
|
// return;
|
||||||
|
//
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// // this should tolerate certain number of errors
|
||||||
|
// var result = CopyChunkToOutput(_songBuffer, _vc);
|
||||||
|
// if (!result)
|
||||||
|
// cancelSource.Cancel();
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
|
||||||
|
// cancelSource.Cancel();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }, null, 20);
|
||||||
|
//
|
||||||
|
// while(true)
|
||||||
|
// await Task.Delay(1000, cancelToken);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// start sending data
|
||||||
|
var ticksPerMs = 1000f / Stopwatch.Frequency;
|
||||||
|
sw.Start();
|
||||||
|
Thread.Sleep(2);
|
||||||
|
|
||||||
|
var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3;
|
||||||
|
|
||||||
|
var errorCount = 0;
|
||||||
|
while (!IsStopped && !IsKilled)
|
||||||
|
{
|
||||||
|
// doing the skip this way instead of in the condition
|
||||||
|
// ensures that a song will for sure be skipped
|
||||||
|
if (skipped)
|
||||||
|
{
|
||||||
|
skipped = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPaused)
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Restart();
|
||||||
|
var ticks = sw.ElapsedTicks;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = CopyChunkToOutput(_songBuffer, _vc);
|
||||||
|
|
||||||
|
// if song is finished
|
||||||
|
if (result is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (result is true)
|
||||||
|
{
|
||||||
|
if (errorCount > 0)
|
||||||
|
{
|
||||||
|
_ = _proxy.StartSpeakingAsync();
|
||||||
|
errorCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FUTURE windows multimedia api
|
||||||
|
|
||||||
|
// wait for slightly less than the latency
|
||||||
|
Thread.Sleep(delay);
|
||||||
|
|
||||||
|
// and then spin out the rest
|
||||||
|
while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f)
|
||||||
|
Thread.SpinWait(100);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// result is false is either when the gateway is being swapped
|
||||||
|
// or if the bot is reconnecting, or just disconnected for whatever reason
|
||||||
|
|
||||||
|
// tolerate up to 15x200ms of failures (3 seconds)
|
||||||
|
if (++errorCount <= 15)
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("Can't send data to voice channel");
|
||||||
|
|
||||||
|
IsStopped = true;
|
||||||
|
// if errors are happening for more than 3 seconds
|
||||||
|
// Stop the player
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
IsStopped = true;
|
||||||
|
Log.Error("Please install ffmpeg and make sure it's added to your "
|
||||||
|
+ "PATH environment variable before trying again");
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Log.Information("Song skipped");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message);
|
||||||
|
await Task.Delay(3_000);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cancellationTokenSource.Cancel();
|
||||||
|
// turn off green in vc
|
||||||
|
|
||||||
|
_ = OnCompleted?.Invoke(this, track);
|
||||||
|
|
||||||
|
if (AutoPlay && track.Platform == MusicPlatform.Youtube)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5);
|
||||||
|
var related = relatedSongs.Shuffle().FirstOrDefault();
|
||||||
|
if (related is not null)
|
||||||
|
{
|
||||||
|
var relatedTrack =
|
||||||
|
await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube);
|
||||||
|
if (relatedTrack is not null)
|
||||||
|
EnqueueTrack(relatedTrack, "Autoplay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed queueing a related song via autoplay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HandleQueuePostTrack();
|
||||||
|
skipped = false;
|
||||||
|
|
||||||
|
_ = _proxy.StopSpeakingAsync();
|
||||||
|
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> GetStreamUrl(IQueuedTrackInfo track)
|
||||||
|
{
|
||||||
|
if (track.TrackInfo is SimpleTrackInfo sti)
|
||||||
|
return sti.StreamUrl;
|
||||||
|
|
||||||
|
return await _ytResolverFactory.GetYoutubeResolver().GetStreamUrl(track.TrackInfo.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc)
|
||||||
|
{
|
||||||
|
var data = sb.Read(vc.InputLength, out var length);
|
||||||
|
|
||||||
|
// if nothing is read from the buffer, song is finished
|
||||||
|
if (data.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
_adjustVolume(data, Volume);
|
||||||
|
return _proxy.SendPcmFrame(vc, data, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleQueuePostTrack()
|
||||||
|
{
|
||||||
|
if (forceIndex is { } index)
|
||||||
|
{
|
||||||
|
_queue.SetIndex(index);
|
||||||
|
forceIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (repeat, isStopped) = (Repeat, IsStopped);
|
||||||
|
|
||||||
|
if (repeat == PlayerRepeatType.Track || isStopped)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// if queue is being repeated, advance no matter what
|
||||||
|
if (repeat == PlayerRepeatType.None)
|
||||||
|
{
|
||||||
|
// if this is the last song,
|
||||||
|
// stop the queue
|
||||||
|
if (_queue.IsLast())
|
||||||
|
{
|
||||||
|
IsStopped = true;
|
||||||
|
OnQueueStopped?.Invoke(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.Advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static void AdjustVolumeInt16(Span<byte> audioSamples, float volume)
|
||||||
|
{
|
||||||
|
if (Math.Abs(volume - 1f) < 0.0001f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var samples = MemoryMarshal.Cast<byte, short>(audioSamples);
|
||||||
|
|
||||||
|
for (var i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
ref var sample = ref samples[i];
|
||||||
|
sample = (short)(sample * volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static void AdjustVolumeFloat32(Span<byte> audioSamples, float volume)
|
||||||
|
{
|
||||||
|
if (Math.Abs(volume - 1f) < 0.0001f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var samples = MemoryMarshal.Cast<byte, float>(audioSamples);
|
||||||
|
|
||||||
|
for (var i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
ref var sample = ref samples[i];
|
||||||
|
sample *= volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
|
||||||
|
string query,
|
||||||
|
string queuer,
|
||||||
|
bool asNext,
|
||||||
|
MusicPlatform? forcePlatform = null)
|
||||||
|
{
|
||||||
|
var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform);
|
||||||
|
if (song is null)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
int index;
|
||||||
|
|
||||||
|
if (asNext)
|
||||||
|
return (_queue.EnqueueNext(song, queuer, out index), index);
|
||||||
|
|
||||||
|
return (_queue.Enqueue(song, queuer, out index), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer)
|
||||||
|
{
|
||||||
|
var errorCount = 0;
|
||||||
|
foreach (var chunk in queries.Chunk(5))
|
||||||
|
{
|
||||||
|
if (IsKilled)
|
||||||
|
break;
|
||||||
|
|
||||||
|
await chunk.Select(async data =>
|
||||||
|
{
|
||||||
|
var (query, platform) = data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TryEnqueueTrackAsync(query, queuer, false, platform);
|
||||||
|
errorCount = 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
|
||||||
|
++errorCount;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WhenAll();
|
||||||
|
|
||||||
|
await Task.Delay(1000);
|
||||||
|
|
||||||
|
// > 10 errors in a row = kill
|
||||||
|
if (errorCount > 10)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueTrack(ITrackInfo track, string queuer)
|
||||||
|
=> _queue.Enqueue(track, queuer, out _);
|
||||||
|
|
||||||
|
public void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer)
|
||||||
|
=> _queue.EnqueueMany(tracks, queuer);
|
||||||
|
|
||||||
|
public void SetRepeat(PlayerRepeatType type)
|
||||||
|
=> Repeat = type;
|
||||||
|
|
||||||
|
public void ShuffleQueue()
|
||||||
|
=> _queue.Shuffle(_rng);
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
=> IsStopped = true;
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_queue.Clear();
|
||||||
|
skipped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks()
|
||||||
|
=> _queue.List();
|
||||||
|
|
||||||
|
public IQueuedTrackInfo? GetCurrentTrack(out int index)
|
||||||
|
=> _queue.GetCurrent(out index);
|
||||||
|
|
||||||
|
public void Next()
|
||||||
|
{
|
||||||
|
skipped = true;
|
||||||
|
IsStopped = false;
|
||||||
|
IsPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MoveTo(int index)
|
||||||
|
{
|
||||||
|
if (_queue.SetIndex(index))
|
||||||
|
{
|
||||||
|
forceIndex = index;
|
||||||
|
skipped = true;
|
||||||
|
IsStopped = false;
|
||||||
|
IsPaused = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetVolume(int newVolume)
|
||||||
|
{
|
||||||
|
var normalizedVolume = newVolume / 100f;
|
||||||
|
if (normalizedVolume is < 0f or > 1f)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100");
|
||||||
|
|
||||||
|
Volume = normalizedVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill()
|
||||||
|
{
|
||||||
|
IsKilled = true;
|
||||||
|
IsStopped = true;
|
||||||
|
IsPaused = false;
|
||||||
|
skipped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo)
|
||||||
|
{
|
||||||
|
if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (isCurrent)
|
||||||
|
skipped = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TogglePause()
|
||||||
|
=> IsPaused = !IsPaused;
|
||||||
|
|
||||||
|
public IQueuedTrackInfo? MoveTrack(int from, int to)
|
||||||
|
=> _queue.MoveTrack(from, to);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsKilled = true;
|
||||||
|
OnCompleted = null;
|
||||||
|
OnStarted = null;
|
||||||
|
OnQueueStopped = null;
|
||||||
|
_queue.Clear();
|
||||||
|
_songBuffer.Dispose();
|
||||||
|
_vc.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate void AdjustVolumeDelegate(Span<byte> data, float volume);
|
||||||
|
|
||||||
|
public void SetFairplay()
|
||||||
|
{
|
||||||
|
_queue.ReorderFairly();
|
||||||
|
}
|
||||||
|
}
|
342
src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs
Normal file
342
src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs
Normal file
|
@ -0,0 +1,342 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed partial class MusicQueue
|
||||||
|
{
|
||||||
|
private sealed class QueuedTrackInfo : IQueuedTrackInfo
|
||||||
|
{
|
||||||
|
public ITrackInfo TrackInfo { get; }
|
||||||
|
public string Queuer { get; }
|
||||||
|
|
||||||
|
public string Title
|
||||||
|
=> TrackInfo.Title;
|
||||||
|
|
||||||
|
public string Url
|
||||||
|
=> TrackInfo.Url;
|
||||||
|
|
||||||
|
public string Thumbnail
|
||||||
|
=> TrackInfo.Thumbnail;
|
||||||
|
|
||||||
|
public TimeSpan Duration
|
||||||
|
=> TrackInfo.Duration;
|
||||||
|
|
||||||
|
public MusicPlatform Platform
|
||||||
|
=> TrackInfo.Platform;
|
||||||
|
|
||||||
|
|
||||||
|
public QueuedTrackInfo(ITrackInfo trackInfo, string queuer)
|
||||||
|
{
|
||||||
|
TrackInfo = trackInfo;
|
||||||
|
Queuer = queuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class MusicQueue : IMusicQueue
|
||||||
|
{
|
||||||
|
public int Index
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// just make sure the internal logic runs first
|
||||||
|
// to make sure that some potential intermediate value is not returned
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
return tracks.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LinkedList<QueuedTrackInfo> tracks;
|
||||||
|
|
||||||
|
private int index;
|
||||||
|
|
||||||
|
private readonly object _locker = new();
|
||||||
|
|
||||||
|
public MusicQueue()
|
||||||
|
{
|
||||||
|
index = 0;
|
||||||
|
tracks = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int enqueuedAt)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
var added = new QueuedTrackInfo(trackInfo, queuer);
|
||||||
|
enqueuedAt = tracks.Count;
|
||||||
|
tracks.AddLast(added);
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int trackIndex)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
if (tracks.Count == 0)
|
||||||
|
return Enqueue(trackInfo, queuer, out trackIndex);
|
||||||
|
|
||||||
|
var currentNode = tracks.First!;
|
||||||
|
int i;
|
||||||
|
for (i = 1; i <= index; i++)
|
||||||
|
currentNode = currentNode.Next!; // can't be null because index is always in range of the count
|
||||||
|
|
||||||
|
var added = new QueuedTrackInfo(trackInfo, queuer);
|
||||||
|
trackIndex = i;
|
||||||
|
|
||||||
|
tracks.AddAfter(currentNode, added);
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueMany(IEnumerable<ITrackInfo> toEnqueue, string queuer)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
foreach (var track in toEnqueue)
|
||||||
|
{
|
||||||
|
var added = new QueuedTrackInfo(track, queuer);
|
||||||
|
tracks.AddLast(added);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<IQueuedTrackInfo> List()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
return tracks.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueuedTrackInfo? GetCurrent(out int currentIndex)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
currentIndex = index;
|
||||||
|
return tracks.ElementAtOrDefault(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Advance()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
if (++index >= tracks.Count)
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
tracks.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SetIndex(int newIndex)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
if (newIndex < 0 || newIndex >= tracks.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
index = newIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveAtInternal(int remoteAtIndex, out IQueuedTrackInfo trackInfo)
|
||||||
|
{
|
||||||
|
var removedNode = tracks.First!;
|
||||||
|
int i;
|
||||||
|
for (i = 0; i < remoteAtIndex; i++)
|
||||||
|
removedNode = removedNode.Next!;
|
||||||
|
|
||||||
|
trackInfo = removedNode.Value;
|
||||||
|
tracks.Remove(removedNode);
|
||||||
|
|
||||||
|
if (i <= index)
|
||||||
|
--index;
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
index = Count;
|
||||||
|
|
||||||
|
// if it was the last song in the queue
|
||||||
|
// // wrap back to start
|
||||||
|
// if (_index == Count)
|
||||||
|
// _index = 0;
|
||||||
|
// else if (i <= _index)
|
||||||
|
// if (_index == 0)
|
||||||
|
// _index = Count;
|
||||||
|
// else --_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveCurrent()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
if (index < tracks.Count)
|
||||||
|
RemoveAtInternal(index, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueuedTrackInfo? MoveTrack(int from, int to)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(from);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(to);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfEqual(to, from);
|
||||||
|
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
if (from >= Count || to >= Count)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// update current track index
|
||||||
|
if (from == index)
|
||||||
|
{
|
||||||
|
// if the song being moved is the current track
|
||||||
|
// it means that it will for sure end up on the destination
|
||||||
|
index = to;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// moving a track from below the current track means
|
||||||
|
// means it will drop down
|
||||||
|
if (from < index)
|
||||||
|
index--;
|
||||||
|
|
||||||
|
// moving a track to below the current track
|
||||||
|
// means it will rise up
|
||||||
|
if (to <= index)
|
||||||
|
index++;
|
||||||
|
|
||||||
|
|
||||||
|
// if both from and to are below _index - net change is + 1 - 1 = 0
|
||||||
|
// if from is below and to is above - net change is -1 (as the track is taken and put above)
|
||||||
|
// if from is above and to is below - net change is 1 (as the track is inserted under)
|
||||||
|
// if from is above and to is above - net change is 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the node which needs to be moved
|
||||||
|
var fromNode = tracks.First!;
|
||||||
|
for (var i = 0; i < from; i++)
|
||||||
|
fromNode = fromNode.Next!;
|
||||||
|
|
||||||
|
// remove it from the queue
|
||||||
|
tracks.Remove(fromNode);
|
||||||
|
|
||||||
|
// if it needs to be added as a first node,
|
||||||
|
// add it directly and return
|
||||||
|
if (to == 0)
|
||||||
|
{
|
||||||
|
tracks.AddFirst(fromNode);
|
||||||
|
return fromNode.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// else find the node at the index before the specified target
|
||||||
|
var addAfterNode = tracks.First!;
|
||||||
|
for (var i = 1; i < to; i++)
|
||||||
|
addAfterNode = addAfterNode.Next!;
|
||||||
|
|
||||||
|
// and add after it
|
||||||
|
tracks.AddAfter(addAfterNode, fromNode);
|
||||||
|
return fromNode.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shuffle(Random rng)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
var list = tracks.ToArray();
|
||||||
|
rng.Shuffle(list);
|
||||||
|
tracks = new(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLast()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
return index == tracks.Count // if there are no tracks
|
||||||
|
|| index == tracks.Count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReorderFairly()
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
var groups = new Dictionary<string, int>();
|
||||||
|
var queuers = new List<Queue<QueuedTrackInfo>>();
|
||||||
|
|
||||||
|
foreach (var track in tracks.Skip(index).Concat(tracks.Take(index)))
|
||||||
|
{
|
||||||
|
if (!groups.TryGetValue(track.Queuer, out var qIndex))
|
||||||
|
{
|
||||||
|
queuers.Add(new Queue<QueuedTrackInfo>());
|
||||||
|
qIndex = queuers.Count - 1;
|
||||||
|
groups.Add(track.Queuer, qIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
queuers[qIndex].Enqueue(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = new LinkedList<QueuedTrackInfo>();
|
||||||
|
index = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < queuers.Count; i++)
|
||||||
|
{
|
||||||
|
var queue = queuers[i];
|
||||||
|
tracks.AddLast(queue.Dequeue());
|
||||||
|
|
||||||
|
if (queue.Count == 0)
|
||||||
|
{
|
||||||
|
queuers.RemoveAt(i);
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queuers.Count == 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRemoveAt(int remoteAt, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
|
||||||
|
{
|
||||||
|
lock (_locker)
|
||||||
|
{
|
||||||
|
isCurrent = false;
|
||||||
|
trackInfo = null;
|
||||||
|
|
||||||
|
if (remoteAt < 0 || remoteAt >= tracks.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (remoteAt == index)
|
||||||
|
isCurrent = true;
|
||||||
|
|
||||||
|
RemoveAtInternal(remoteAt, out trackInfo);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed record RemoteTrackInfo(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Url,
|
||||||
|
string Thumbnail,
|
||||||
|
TimeSpan Duration,
|
||||||
|
MusicPlatform Platform) : ITrackInfo;
|
27
src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs
Normal file
27
src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class SimpleTrackInfo : ITrackInfo
|
||||||
|
{
|
||||||
|
public string Title { get; }
|
||||||
|
public string Url { get; }
|
||||||
|
public string Thumbnail { get; }
|
||||||
|
public TimeSpan Duration { get; }
|
||||||
|
public MusicPlatform Platform { get; }
|
||||||
|
public string? StreamUrl { get; }
|
||||||
|
|
||||||
|
public SimpleTrackInfo(
|
||||||
|
string title,
|
||||||
|
string url,
|
||||||
|
string thumbnail,
|
||||||
|
TimeSpan duration,
|
||||||
|
MusicPlatform platform,
|
||||||
|
string streamUrl)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Url = url;
|
||||||
|
Thumbnail = thumbnail;
|
||||||
|
Duration = duration;
|
||||||
|
Platform = platform;
|
||||||
|
StreamUrl = streamUrl;
|
||||||
|
}
|
||||||
|
}
|
105
src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs
Normal file
105
src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class TrackCacher : ITrackCacher
|
||||||
|
{
|
||||||
|
private readonly IBotCache _cache;
|
||||||
|
|
||||||
|
public TrackCacher(IBotCache cache)
|
||||||
|
=> _cache = cache;
|
||||||
|
|
||||||
|
|
||||||
|
private TypedKey<string> GetStreamLinkKey(MusicPlatform platform, string id)
|
||||||
|
=> new($"music:stream:{platform}:{id}");
|
||||||
|
|
||||||
|
public async Task<string?> GetOrCreateStreamLink(
|
||||||
|
string id,
|
||||||
|
MusicPlatform platform,
|
||||||
|
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
|
||||||
|
{
|
||||||
|
var key = GetStreamLinkKey(platform, id);
|
||||||
|
|
||||||
|
var streamUrl = await _cache.GetOrDefaultAsync(key);
|
||||||
|
await _cache.RemoveAsync(key);
|
||||||
|
|
||||||
|
if (streamUrl == default)
|
||||||
|
{
|
||||||
|
(streamUrl, _) = await streamUrlFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// make a new one for later use
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
(streamUrl, var expiry) = await streamUrlFactory();
|
||||||
|
await CacheStreamUrlAsync(id, platform, streamUrl, expiry);
|
||||||
|
});
|
||||||
|
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CacheStreamUrlAsync(
|
||||||
|
string id,
|
||||||
|
MusicPlatform platform,
|
||||||
|
string url,
|
||||||
|
TimeSpan expiry)
|
||||||
|
=> await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry);
|
||||||
|
|
||||||
|
// track data by id
|
||||||
|
private TypedKey<CachableTrackData> GetTrackDataKey(MusicPlatform platform, string id)
|
||||||
|
=> new($"music:track:{platform}:{id}");
|
||||||
|
public async Task CacheTrackDataAsync(ICachableTrackData data)
|
||||||
|
=> await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data));
|
||||||
|
|
||||||
|
private CachableTrackData ToCachableTrackData(ICachableTrackData data)
|
||||||
|
=> new CachableTrackData()
|
||||||
|
{
|
||||||
|
Id = data.Id,
|
||||||
|
Platform = data.Platform,
|
||||||
|
Thumbnail = data.Thumbnail,
|
||||||
|
Title = data.Title,
|
||||||
|
Url = data.Url,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
|
||||||
|
=> await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id));
|
||||||
|
|
||||||
|
|
||||||
|
// track data by query
|
||||||
|
private TypedKey<CachableTrackData> GetTrackDataQueryKey(MusicPlatform platform, string query)
|
||||||
|
=> new($"music:track:{platform}:q:{query}");
|
||||||
|
|
||||||
|
public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
|
||||||
|
=> await Task.WhenAll(
|
||||||
|
_cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), ToCachableTrackData(data)).AsTask(),
|
||||||
|
_cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)).AsTask());
|
||||||
|
|
||||||
|
public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
|
||||||
|
=> await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query));
|
||||||
|
|
||||||
|
|
||||||
|
// playlist track ids by playlist id
|
||||||
|
private TypedKey<List<string>> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform)
|
||||||
|
=> new($"music:playlist_tracks:{platform}:{playlist}");
|
||||||
|
|
||||||
|
public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
|
||||||
|
=> await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList());
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
|
||||||
|
{
|
||||||
|
var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform));
|
||||||
|
if (result.TryGetValue(out var val))
|
||||||
|
return val;
|
||||||
|
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// playlist id by query
|
||||||
|
private TypedKey<string> GetPlaylistCacheKey(string query, MusicPlatform platform)
|
||||||
|
=> new($"music:playlist_id:{platform}:{query}");
|
||||||
|
|
||||||
|
public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
|
||||||
|
=> await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId);
|
||||||
|
|
||||||
|
public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
|
||||||
|
=> await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform));
|
||||||
|
}
|
102
src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs
Normal file
102
src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
#nullable disable
|
||||||
|
using EllieBot.Voice;
|
||||||
|
using EllieBot.Voice.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class VoiceProxy : IVoiceProxy
|
||||||
|
{
|
||||||
|
public enum VoiceProxyState
|
||||||
|
{
|
||||||
|
Created,
|
||||||
|
Started,
|
||||||
|
Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MAX_ERROR_COUNT = 20;
|
||||||
|
private const int DELAY_ON_ERROR_MILISECONDS = 200;
|
||||||
|
|
||||||
|
public VoiceProxyState State
|
||||||
|
=> gateway switch
|
||||||
|
{
|
||||||
|
{ Started: true, Stopped: false } => VoiceProxyState.Started,
|
||||||
|
{ Stopped: false } => VoiceProxyState.Created,
|
||||||
|
_ => VoiceProxyState.Stopped
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
private VoiceGateway gateway;
|
||||||
|
|
||||||
|
public VoiceProxy(VoiceGateway initial)
|
||||||
|
=> gateway = initial;
|
||||||
|
|
||||||
|
public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gw = gateway;
|
||||||
|
if (gw is null || gw.Stopped || !gw.Started)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
vc.SendPcmFrame(gw, data, 0, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RunGatewayAction(Func<VoiceGateway, Task> action)
|
||||||
|
{
|
||||||
|
var errorCount = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if (State == VoiceProxyState.Stopped)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gw = gateway;
|
||||||
|
if (gw is null || !gw.ConnectingFinished.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
++errorCount;
|
||||||
|
await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
|
||||||
|
Log.Debug("Gateway is not ready");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await action(gw);
|
||||||
|
errorCount = 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
++errorCount;
|
||||||
|
await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
|
||||||
|
Log.Debug(ex, "Error performing proxy gateway action");
|
||||||
|
}
|
||||||
|
} while (errorCount is > 0 and <= MAX_ERROR_COUNT);
|
||||||
|
|
||||||
|
return State != VoiceProxyState.Stopped && errorCount <= MAX_ERROR_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetGateway(VoiceGateway newGateway)
|
||||||
|
=> gateway = newGateway;
|
||||||
|
|
||||||
|
public Task StartSpeakingAsync()
|
||||||
|
=> RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.Microphone));
|
||||||
|
|
||||||
|
public Task StopSpeakingAsync()
|
||||||
|
=> RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.None));
|
||||||
|
|
||||||
|
public async Task StartGateway()
|
||||||
|
=> await gateway.Start();
|
||||||
|
|
||||||
|
public Task StopGateway()
|
||||||
|
{
|
||||||
|
if (gateway is { } gw)
|
||||||
|
return gw.StopAsync();
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
12
src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
Normal file
12
src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class InvTrackInfo : ITrackInfo
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string Url { get; init; }
|
||||||
|
public required string Thumbnail { get; init; }
|
||||||
|
public required TimeSpan Duration { get; init; }
|
||||||
|
public required MusicPlatform Platform { get; init; }
|
||||||
|
public required string? StreamUrl { get; init; }
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
using EllieBot.Modules.Searches;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class InvidiousYoutubeResolver : IYoutubeResolver
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly SearchesConfigService _sc;
|
||||||
|
private readonly EllieRandom _rng;
|
||||||
|
|
||||||
|
private string InvidiousApiUrl
|
||||||
|
=> _sc.Data.InvidiousInstances[_rng.Next(0, _sc.Data.InvidiousInstances.Count)];
|
||||||
|
|
||||||
|
public InvidiousYoutubeResolver(IHttpClientFactory httpFactory, SearchesConfigService sc)
|
||||||
|
{
|
||||||
|
_rng = new EllieRandom();
|
||||||
|
_httpFactory = httpFactory;
|
||||||
|
_sc = sc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
|
||||||
|
var items = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/search"
|
||||||
|
+ $"?q={query}"
|
||||||
|
+ $"&type=video");
|
||||||
|
|
||||||
|
if (items is null || items.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
|
||||||
|
var res = items.First();
|
||||||
|
|
||||||
|
return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = res.VideoId,
|
||||||
|
Title = res.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={res.VideoId}",
|
||||||
|
Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(res.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByIdAsync(string id)
|
||||||
|
=> await InternalResolveByIdAsync(id);
|
||||||
|
|
||||||
|
private async Task<InvTrackInfo?> InternalResolveByIdAsync(string id)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
|
||||||
|
var res = await http.GetFromJsonAsync<InvidiousVideoResponse>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/videos/{id}");
|
||||||
|
|
||||||
|
if (res is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = res.VideoId,
|
||||||
|
Title = res.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={res.VideoId}",
|
||||||
|
Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(res.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_HIGH")?.Url
|
||||||
|
?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_MEDIUM")?.Url
|
||||||
|
?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_LOW")?.Url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
var res = await http.GetFromJsonAsync<InvidiousPlaylistResponse>(
|
||||||
|
$"{InvidiousApiUrl}/api/v1/search?type=video&q={query}");
|
||||||
|
|
||||||
|
if (res is null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var video in res.Videos)
|
||||||
|
{
|
||||||
|
yield return new InvTrackInfo()
|
||||||
|
{
|
||||||
|
Id = video.VideoId,
|
||||||
|
Title = video.Title,
|
||||||
|
Url = $"https://youtube.com/watch?v={video.VideoId}",
|
||||||
|
Thumbnail = video.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
|
||||||
|
Duration = TimeSpan.FromSeconds(video.LengthSeconds),
|
||||||
|
Platform = MusicPlatform.Youtube,
|
||||||
|
StreamUrl = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId)
|
||||||
|
=> ResolveByQueryAsync(query);
|
||||||
|
|
||||||
|
public async Task<string?> GetStreamUrl(string videoId)
|
||||||
|
{
|
||||||
|
var video = await InternalResolveByIdAsync(videoId);
|
||||||
|
return video?.StreamUrl;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
public sealed class LocalTrackResolver : ILocalTrackResolver
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> _musicExtensions = new[]
|
||||||
|
{
|
||||||
|
".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX",
|
||||||
|
".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V"
|
||||||
|
}.ToHashSet();
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||||
|
{
|
||||||
|
if (!File.Exists(query))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var trackDuration = await Ffprobe.GetTrackDurationAsync(query);
|
||||||
|
return new SimpleTrackInfo(Path.GetFileNameWithoutExtension(query),
|
||||||
|
$"https://google.com?q={Uri.EscapeDataString(Path.GetFileNameWithoutExtension(query))}",
|
||||||
|
"https://cdn.discordapp.com/attachments/155726317222887425/261850914783100928/1482522077_music.png",
|
||||||
|
trackDuration,
|
||||||
|
MusicPlatform.Local,
|
||||||
|
$"\"{Path.GetFullPath(query)}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath)
|
||||||
|
{
|
||||||
|
DirectoryInfo dir;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dir = new(dirPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = dir.EnumerateFiles()
|
||||||
|
.Where(x =>
|
||||||
|
{
|
||||||
|
if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System)
|
||||||
|
&& _musicExtensions.Contains(x.Extension.ToUpperInvariant()))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var firstFile = files.FirstOrDefault()?.FullName;
|
||||||
|
if (firstFile is null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
var firstData = await ResolveByQueryAsync(firstFile);
|
||||||
|
if (firstData is not null)
|
||||||
|
yield return firstData;
|
||||||
|
|
||||||
|
var fileChunks = files.Skip(1).Chunk(10);
|
||||||
|
foreach (var chunk in fileChunks)
|
||||||
|
{
|
||||||
|
var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll();
|
||||||
|
|
||||||
|
// nullable reference types being annoying
|
||||||
|
foreach (var p in part)
|
||||||
|
{
|
||||||
|
if (p is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
yield return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Ffprobe
|
||||||
|
{
|
||||||
|
public static async Task<TimeSpan> GetTrackDurationAsync(string query)
|
||||||
|
{
|
||||||
|
query = query.Replace("\"", "");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var p = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "ffprobe",
|
||||||
|
Arguments =
|
||||||
|
$"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 -- \"{query}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p is null)
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
|
||||||
|
var data = await p.StandardOutput.ReadToEndAsync();
|
||||||
|
if (double.TryParse(data, out var seconds))
|
||||||
|
return TimeSpan.FromSeconds(seconds);
|
||||||
|
|
||||||
|
var errorData = await p.StandardError.ReadToEndAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(errorData))
|
||||||
|
Log.Warning("Ffprobe warning for file {FileName}: {ErrorMessage}", query, errorData);
|
||||||
|
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
Log.Warning("Ffprobe was likely not installed. Local song durations will show as (?)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
public class RadioResolver : IRadioResolver
|
||||||
|
{
|
||||||
|
private readonly Regex _plsRegex = new(@"File1=(?<url>.*?)\n", RegexOptions.Compiled);
|
||||||
|
private readonly Regex _m3URegex = new(@"(?<url>^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline);
|
||||||
|
private readonly Regex _asxRegex = new(@"<ref href=""(?<url>.*?)""", RegexOptions.Compiled);
|
||||||
|
private readonly Regex _xspfRegex = new(@"<location>(?<url>.*?)</location>", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public async Task<ITrackInfo> ResolveByQueryAsync(string query)
|
||||||
|
{
|
||||||
|
if (IsRadioLink(query))
|
||||||
|
query = await HandleStreamContainers(query);
|
||||||
|
|
||||||
|
return new SimpleTrackInfo(query.TrimTo(50),
|
||||||
|
query,
|
||||||
|
"https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png",
|
||||||
|
TimeSpan.MaxValue,
|
||||||
|
MusicPlatform.Radio,
|
||||||
|
query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRadioLink(string query)
|
||||||
|
=> (query.StartsWith("http", StringComparison.InvariantCulture)
|
||||||
|
|| query.StartsWith("ww", StringComparison.InvariantCulture))
|
||||||
|
&& (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
|
||||||
|
|
||||||
|
private async Task<string> HandleStreamContainers(string query)
|
||||||
|
{
|
||||||
|
string file = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
file = await http.GetStringAsync(query);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Contains(".pls"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var m = _plsRegex.Match(file);
|
||||||
|
var res = m.Groups["url"]?.ToString();
|
||||||
|
return res?.Trim();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("Failed reading .pls:\n{PlsFile}", file);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Contains(".m3u"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var m = _m3URegex.Match(file);
|
||||||
|
var res = m.Groups["url"].ToString();
|
||||||
|
return res.Trim();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("Failed reading .m3u:\n{M3uFile}", file);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Contains(".asx"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var m = _asxRegex.Match(file);
|
||||||
|
var res = m.Groups["url"].ToString();
|
||||||
|
return res.Trim();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("Failed reading .asx:\n{AsxFile}", file);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Contains(".xspf"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var m = _xspfRegex.Match(file);
|
||||||
|
var res = m.Groups["url"].ToString();
|
||||||
|
return res.Trim();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("Failed reading .xspf:\n{XspfFile}", file);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
using EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class TrackResolveProvider : ITrackResolveProvider
|
||||||
|
{
|
||||||
|
private readonly IYoutubeResolverFactory _ytResolver;
|
||||||
|
private readonly ILocalTrackResolver _localResolver;
|
||||||
|
private readonly IRadioResolver _radioResolver;
|
||||||
|
|
||||||
|
public TrackResolveProvider(
|
||||||
|
IYoutubeResolverFactory ytResolver,
|
||||||
|
ILocalTrackResolver localResolver,
|
||||||
|
IRadioResolver radioResolver)
|
||||||
|
{
|
||||||
|
_ytResolver = ytResolver;
|
||||||
|
_localResolver = localResolver;
|
||||||
|
_radioResolver = radioResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform)
|
||||||
|
{
|
||||||
|
switch (forcePlatform)
|
||||||
|
{
|
||||||
|
case MusicPlatform.Radio:
|
||||||
|
return _radioResolver.ResolveByQueryAsync(query);
|
||||||
|
case MusicPlatform.Youtube:
|
||||||
|
return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query);
|
||||||
|
case MusicPlatform.Local:
|
||||||
|
return _localResolver.ResolveByQueryAsync(query);
|
||||||
|
case null:
|
||||||
|
var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
return _ytResolver.GetYoutubeResolver().ResolveByIdAsync(match.Groups["id"].Value);
|
||||||
|
|
||||||
|
if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile)
|
||||||
|
return _localResolver.ResolveByQueryAsync(uri.AbsolutePath);
|
||||||
|
|
||||||
|
if (IsRadioLink(query))
|
||||||
|
return _radioResolver.ResolveByQueryAsync(query);
|
||||||
|
|
||||||
|
return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query, false);
|
||||||
|
default:
|
||||||
|
Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform);
|
||||||
|
return Task.FromResult<ITrackInfo?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRadioLink(string query)
|
||||||
|
=> (query.StartsWith("http", StringComparison.InvariantCulture)
|
||||||
|
|| query.StartsWith("ww", StringComparison.InvariantCulture))
|
||||||
|
&& (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class YoutubeHelpers
|
||||||
|
{
|
||||||
|
public static Regex YtVideoIdRegex { get; } =
|
||||||
|
new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieBot.Modules.Searches;
|
||||||
|
using EllieBot.Modules.Searches.Services;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music.Resolvers;
|
||||||
|
|
||||||
|
public interface IYoutubeResolverFactory
|
||||||
|
{
|
||||||
|
IYoutubeResolver GetYoutubeResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class YoutubeResolverFactory : IYoutubeResolverFactory
|
||||||
|
{
|
||||||
|
private readonly SearchesConfigService _ss;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
public YoutubeResolverFactory(SearchesConfigService ss, IServiceProvider services)
|
||||||
|
{
|
||||||
|
_ss = ss;
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IYoutubeResolver GetYoutubeResolver()
|
||||||
|
{
|
||||||
|
var conf = _ss.Data;
|
||||||
|
if (conf.YtProvider == YoutubeSearcher.Invidious)
|
||||||
|
{
|
||||||
|
return _services.GetRequiredService<InvidiousYoutubeResolver>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _services.GetRequiredService<YtdlYoutubeResolver>();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,316 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Music;
|
||||||
|
|
||||||
|
public sealed class YtdlYoutubeResolver : IYoutubeResolver
|
||||||
|
{
|
||||||
|
private static readonly string[] _durationFormats =
|
||||||
|
[
|
||||||
|
"ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Regex _expiryRegex = new(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
|
||||||
|
|
||||||
|
|
||||||
|
private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
|
||||||
|
private readonly ITrackCacher _trackCacher;
|
||||||
|
|
||||||
|
private readonly YtdlOperation _ytdlPlaylistOperation;
|
||||||
|
private readonly YtdlOperation _ytdlIdOperation;
|
||||||
|
private readonly YtdlOperation _ytdlSearchOperation;
|
||||||
|
|
||||||
|
private readonly IGoogleApiService _google;
|
||||||
|
|
||||||
|
public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google, SearchesConfigService scs)
|
||||||
|
{
|
||||||
|
_trackCacher = trackCacher;
|
||||||
|
_google = google;
|
||||||
|
|
||||||
|
|
||||||
|
_ytdlPlaylistOperation = new("-4 "
|
||||||
|
+ "--geo-bypass "
|
||||||
|
+ "--encoding UTF8 "
|
||||||
|
+ "-f bestaudio "
|
||||||
|
+ "-e "
|
||||||
|
+ "--get-url "
|
||||||
|
+ "--get-id "
|
||||||
|
+ "--get-thumbnail "
|
||||||
|
+ "--get-duration "
|
||||||
|
+ "--no-check-certificate "
|
||||||
|
+ "-i "
|
||||||
|
+ "--yes-playlist "
|
||||||
|
+ "-- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
|
|
||||||
|
_ytdlIdOperation = new("-4 "
|
||||||
|
+ "--geo-bypass "
|
||||||
|
+ "--encoding UTF8 "
|
||||||
|
+ "-f bestaudio "
|
||||||
|
+ "-e "
|
||||||
|
+ "--get-url "
|
||||||
|
+ "--get-id "
|
||||||
|
+ "--get-thumbnail "
|
||||||
|
+ "--get-duration "
|
||||||
|
+ "--no-check-certificate "
|
||||||
|
+ "-- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
|
|
||||||
|
_ytdlSearchOperation = new("-4 "
|
||||||
|
+ "--geo-bypass "
|
||||||
|
+ "--encoding UTF8 "
|
||||||
|
+ "-f bestaudio "
|
||||||
|
+ "-e "
|
||||||
|
+ "--get-url "
|
||||||
|
+ "--get-id "
|
||||||
|
+ "--get-thumbnail "
|
||||||
|
+ "--get-duration "
|
||||||
|
+ "--no-check-certificate "
|
||||||
|
+ "--default-search "
|
||||||
|
+ "\"ytsearch:\" -- \"{0}\"",
|
||||||
|
scs.Data.YtProvider != YoutubeSearcher.Ytdl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private YtTrackData ResolveYtdlData(string ytdlOutputString)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ytdlOutputString))
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var dataArray = ytdlOutputString.Trim().Split('\n');
|
||||||
|
|
||||||
|
if (dataArray.Length < 5)
|
||||||
|
{
|
||||||
|
Log.Information("Not enough data received: {YtdlData}", ytdlOutputString);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TimeSpan.TryParseExact(dataArray[4], _durationFormats, CultureInfo.InvariantCulture, out var time))
|
||||||
|
time = TimeSpan.Zero;
|
||||||
|
|
||||||
|
var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty;
|
||||||
|
|
||||||
|
return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ITrackInfo DataToInfo(in YtTrackData trackData)
|
||||||
|
=> new RemoteTrackInfo(
|
||||||
|
trackData.Id,
|
||||||
|
trackData.Title,
|
||||||
|
$"https://youtube.com/watch?v={trackData.Id}",
|
||||||
|
trackData.Thumbnail,
|
||||||
|
trackData.Duration,
|
||||||
|
MusicPlatform.Youtube);
|
||||||
|
|
||||||
|
private Func<Task<string?>> CreateCacherFactory(string id)
|
||||||
|
=> () => _trackCacher.GetOrCreateStreamLink(id,
|
||||||
|
MusicPlatform.Youtube,
|
||||||
|
async () => await ExtractNewStreamUrlAsync(id));
|
||||||
|
|
||||||
|
private static TimeSpan GetExpiry(string streamUrl)
|
||||||
|
{
|
||||||
|
var match = _expiryRegex.Match(streamUrl);
|
||||||
|
if (match.Success && double.TryParse(match.Groups["timestamp"].ToString(), out var timestamp))
|
||||||
|
{
|
||||||
|
var realExpiry = timestamp.ToUnixTimestamp() - DateTime.UtcNow;
|
||||||
|
if (realExpiry > TimeSpan.FromMinutes(60))
|
||||||
|
return realExpiry.Subtract(TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
|
return realExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeSpan.FromHours(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string StreamUrl, TimeSpan Expiry)> ExtractNewStreamUrlAsync(string id)
|
||||||
|
{
|
||||||
|
var data = await _ytdlIdOperation.GetDataAsync(id);
|
||||||
|
var trackInfo = ResolveYtdlData(data);
|
||||||
|
if (string.IsNullOrWhiteSpace(trackInfo.StreamUrl))
|
||||||
|
return default;
|
||||||
|
|
||||||
|
return (trackInfo.StreamUrl!, GetExpiry(trackInfo.StreamUrl!));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByIdAsync(string id)
|
||||||
|
{
|
||||||
|
id = id.Trim();
|
||||||
|
|
||||||
|
var cachedData = await _trackCacher.GetCachedDataByIdAsync(id, MusicPlatform.Youtube);
|
||||||
|
if (cachedData is null)
|
||||||
|
{
|
||||||
|
Log.Information("Resolving youtube track by Id: {YoutubeId}", id);
|
||||||
|
|
||||||
|
var data = await _ytdlIdOperation.GetDataAsync(id);
|
||||||
|
|
||||||
|
var trackInfo = ResolveYtdlData(data);
|
||||||
|
if (string.IsNullOrWhiteSpace(trackInfo.Title))
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var toReturn = DataToInfo(in trackInfo);
|
||||||
|
|
||||||
|
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)),
|
||||||
|
CacheStreamUrlAsync(trackInfo));
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task CacheStreamUrlAsync(YtTrackData trackInfo)
|
||||||
|
=> _trackCacher.CacheStreamUrlAsync(trackInfo.Id,
|
||||||
|
MusicPlatform.Youtube,
|
||||||
|
trackInfo.StreamUrl!,
|
||||||
|
GetExpiry(trackInfo.StreamUrl!));
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ITrackInfo> ResolveTracksByPlaylistIdAsync(string playlistId)
|
||||||
|
{
|
||||||
|
Log.Information("Resolving youtube tracks from playlist: {PlaylistId}", playlistId);
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
var ids = await _trackCacher.GetPlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube);
|
||||||
|
if (ids.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var trackInfo = await ResolveByIdAsync(id);
|
||||||
|
if (trackInfo is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
yield return trackInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = string.Empty;
|
||||||
|
var trackIds = new List<string>();
|
||||||
|
await foreach (var line in _ytdlPlaylistOperation.EnumerateDataAsync(playlistId))
|
||||||
|
{
|
||||||
|
data += line;
|
||||||
|
|
||||||
|
if (++count == 5)
|
||||||
|
{
|
||||||
|
var trackData = ResolveYtdlData(data);
|
||||||
|
data = string.Empty;
|
||||||
|
count = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(trackData.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var info = DataToInfo(in trackData);
|
||||||
|
await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)),
|
||||||
|
CacheStreamUrlAsync(trackData));
|
||||||
|
|
||||||
|
trackIds.Add(trackData.Id);
|
||||||
|
yield return info;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
data += Environment.NewLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _trackCacher.CachePlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube, trackIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
|
||||||
|
{
|
||||||
|
string? playlistId;
|
||||||
|
// try to match playlist id inside the query, if a playlist url has been queried
|
||||||
|
var match = _simplePlaylistRegex.Match(query);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
// if it's a success, just return from that playlist using the id
|
||||||
|
playlistId = match.Groups["id"].ToString();
|
||||||
|
await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
|
||||||
|
yield return track;
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a query is a search term, try the cache
|
||||||
|
playlistId = await _trackCacher.GetPlaylistIdByQueryAsync(query, MusicPlatform.Youtube);
|
||||||
|
if (playlistId is null)
|
||||||
|
{
|
||||||
|
// if it's not in the cache
|
||||||
|
// find playlist id by keyword using google api
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var playlistIds = await _google.GetPlaylistIdsByKeywordsAsync(query);
|
||||||
|
playlistId = playlistIds.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error Getting playlist id via GoogleApi");
|
||||||
|
}
|
||||||
|
|
||||||
|
// if query is not a playlist url
|
||||||
|
// and query result is not in the cache
|
||||||
|
// and api returns no values
|
||||||
|
// it means invalid input has been used,
|
||||||
|
// or google api key is not provided
|
||||||
|
if (playlistId is null)
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache the query -> playlist id for fast future lookup
|
||||||
|
await _trackCacher.CachePlaylistIdByQueryAsync(query, MusicPlatform.Youtube, playlistId);
|
||||||
|
await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
|
||||||
|
yield return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ITrackInfo?> ResolveByQueryAsync(string query)
|
||||||
|
=> ResolveByQueryAsync(query, true);
|
||||||
|
|
||||||
|
public async Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryResolving)
|
||||||
|
{
|
||||||
|
if (tryResolving)
|
||||||
|
{
|
||||||
|
var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
|
||||||
|
if (match.Success)
|
||||||
|
return await ResolveByIdAsync(match.Groups["id"].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Resolving youtube song by search term: {YoutubeQuery}", query);
|
||||||
|
|
||||||
|
var cachedData = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.Youtube);
|
||||||
|
if (cachedData is null || string.IsNullOrWhiteSpace(cachedData.Title))
|
||||||
|
{
|
||||||
|
var stringData = await _ytdlSearchOperation.GetDataAsync(query);
|
||||||
|
var trackData = ResolveYtdlData(stringData);
|
||||||
|
|
||||||
|
var trackInfo = DataToInfo(trackData);
|
||||||
|
await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)),
|
||||||
|
CacheStreamUrlAsync(trackData));
|
||||||
|
return trackInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetStreamUrl(string videoId)
|
||||||
|
=> CreateCacherFactory(videoId)();
|
||||||
|
private readonly struct YtTrackData
|
||||||
|
{
|
||||||
|
public readonly string Title;
|
||||||
|
public readonly string Id;
|
||||||
|
public readonly string Thumbnail;
|
||||||
|
public readonly string? StreamUrl;
|
||||||
|
public readonly TimeSpan Duration;
|
||||||
|
|
||||||
|
public YtTrackData(
|
||||||
|
string title,
|
||||||
|
string id,
|
||||||
|
string thumbnail,
|
||||||
|
string? streamUrl,
|
||||||
|
TimeSpan duration)
|
||||||
|
{
|
||||||
|
Title = title.Trim();
|
||||||
|
Id = id.Trim();
|
||||||
|
Thumbnail = thumbnail;
|
||||||
|
StreamUrl = streamUrl;
|
||||||
|
Duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Db;
|
||||||
|
|
||||||
|
public static class MusicPlayerSettingsExtensions
|
||||||
|
{
|
||||||
|
public static async Task<MusicPlayerSettings> ForGuildAsync(this DbSet<MusicPlayerSettings> settings, ulong guildId)
|
||||||
|
{
|
||||||
|
var toReturn = await settings.AsQueryable().FirstOrDefaultAsync(x => x.GuildId == guildId);
|
||||||
|
|
||||||
|
if (toReturn is null)
|
||||||
|
{
|
||||||
|
var newSettings = new MusicPlayerSettings
|
||||||
|
{
|
||||||
|
GuildId = guildId,
|
||||||
|
PlayerRepeat = PlayerRepeatType.Queue
|
||||||
|
};
|
||||||
|
|
||||||
|
await settings.AddAsync(newSettings);
|
||||||
|
return newSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
}
|
10
src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs
Normal file
10
src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class MusicPlaylist : DbEntity
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Author { get; set; }
|
||||||
|
public ulong AuthorId { get; set; }
|
||||||
|
public List<PlaylistSong> Songs { get; set; } = new();
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
#nullable disable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Db;
|
||||||
|
|
||||||
|
public static class MusicPlaylistExtensions
|
||||||
|
{
|
||||||
|
public static List<MusicPlaylist> GetPlaylistsOnPage(this DbSet<MusicPlaylist> playlists, int num)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(num, 1);
|
||||||
|
|
||||||
|
return playlists.AsQueryable().Skip((num - 1) * 20).Take(20).Include(pl => pl.Songs).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MusicPlaylist GetWithSongs(this DbSet<MusicPlaylist> playlists, int id)
|
||||||
|
=> playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id);
|
||||||
|
}
|
61
src/EllieBot/Modules/Music/_common/db/MusicSettings.cs
Normal file
61
src/EllieBot/Modules/Music/_common/db/MusicSettings.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Db.Models;
|
||||||
|
|
||||||
|
public class MusicPlayerSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Auto generated Id
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id of the guild
|
||||||
|
/// </summary>
|
||||||
|
public ulong GuildId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queue repeat type
|
||||||
|
/// </summary>
|
||||||
|
public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel id the bot will always try to send track related messages to
|
||||||
|
/// </summary>
|
||||||
|
public ulong? MusicChannelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default volume player will be created with
|
||||||
|
/// </summary>
|
||||||
|
public int Volume { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the bot should auto disconnect from the voice channel once the queue is done
|
||||||
|
/// This only has effect if
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDisconnect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selected quality preset for the music player
|
||||||
|
/// </summary>
|
||||||
|
public QualityPreset QualityPreset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the bot will automatically queue related songs
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoPlay { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum QualityPreset
|
||||||
|
{
|
||||||
|
Highest,
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlayerRepeatType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Track,
|
||||||
|
Queue
|
||||||
|
}
|
Loading…
Reference in a new issue