diff --git a/src/EllieBot/Modules/Music/Music.cs b/src/EllieBot/Modules/Music/Music.cs new file mode 100644 index 0000000..3b1393c --- /dev/null +++ b/src/EllieBot/Modules/Music/Music.cs @@ -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 +{ + 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 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 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 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 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(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/PlaylistCommands.cs b/src/EllieBot/Modules/Music/PlaylistCommands.cs new file mode 100644 index 0000000..d64e931 --- /dev/null +++ b/src/EllieBot/Modules/Music/PlaylistCommands.cs @@ -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 + { + 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 playlists; + + await using (var uow = _db.GetDbContext()) + { + playlists = uow.Set().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().FirstOrDefault(x => x.Id == id); + + if (pl is not null) + { + if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id) + { + uow.Set().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().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().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().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().DeleteAsync(); + await uow.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs new file mode 100644 index 0000000..ff47354 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs @@ -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 _voiceProxies = new(); + private readonly ConcurrentDictionary _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(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 InternalConnectToVcAsync(ulong guildId, ulong channelId) + { + var voiceStateUpdatedSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var voiceServerUpdatedSource = + new TaskCompletionSource(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 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); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/IMusicService.cs b/src/EllieBot/Modules/Music/Services/IMusicService.cs new file mode 100644 index 0000000..43bad99 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/IMusicService.cs @@ -0,0 +1,36 @@ +using EllieBot.Db.Models; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Music.Services; + +public interface IMusicService +{ + /// + /// Leave voice channel in the specified guild if it's connected to one + /// + /// Id of the guild + public Task LeaveVoiceChannelAsync(ulong guildId); + + /// + /// Joins the voice channel with the specified id + /// + /// Id of the guild where the voice channel is + /// Id of the voice channel + public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId); + + Task GetOrCreateMusicPlayerAsync(ITextChannel contextChannel); + bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer); + Task EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer); + Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer); + Task SendToOutputAsync(ulong guildId, EmbedBuilder embed); + Task PlayAsync(ulong guildId, ulong voiceChannelId); + Task> SearchVideosAsync(string query); + Task SetMusicChannelAsync(ulong guildId, ulong? channelId); + Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType); + Task SetVolumeAsync(ulong guildId, int value); + Task ToggleAutoDisconnectAsync(ulong guildId); + Task GetMusicQualityAsync(ulong guildId); + Task SetMusicQualityAsync(ulong guildId, QualityPreset preset); + Task ToggleQueueAutoPlayAsync(ulong guildId); + Task FairplayAsync(ulong guildId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/MusicService.cs b/src/EllieBot/Modules/Music/Services/MusicService.cs new file mode 100644 index 0000000..89c848d --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/MusicService.cs @@ -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 _players; + private readonly ConcurrentDictionary _outputChannels; + private readonly ConcurrentDictionary _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(); + _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 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 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 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 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 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 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 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 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> SearchYtLoaderVideosAsync(string query) + { + var result = await _ytLoader.LoadResultsAsync(query); + return result.Select(x => (x.Title, x.Url, x.Thumb)).ToList(); + } + + private async Task> SearchGoogleApiVideosAsync(string query) + { + var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5); + return result.Select(x => (x.Name, x.Url, x.Thumbnail)).ToList(); + } + + public async Task> 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 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 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().ForGuildAsync(guildId); + await uow.SaveChangesAsync(); + + return toReturn; + } + + private async Task ModifySettingsInternalAsync( + ulong guildId, + Action action, + TState state) + { + await using var uow = _db.GetDbContext(); + var ms = await uow.Set().ForGuildAsync(guildId); + action(ms, state); + await uow.SaveChangesAsync(); + _settings[guildId] = ms; + } + + public async Task 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 ToggleAutoDisconnectAsync(ulong guildId) + { + var newState = false; + await ModifySettingsInternalAsync(guildId, + (settings, _) => { newState = settings.AutoDisconnect = !settings.AutoDisconnect; }, + default(object)); + + return newState; + } + + public async Task GetMusicQualityAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var settings = await uow.Set().ForGuildAsync(guildId); + return settings.QualityPreset; + } + + public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset) + => ModifySettingsInternalAsync(guildId, + (settings, _) => { settings.QualityPreset = preset; }, + preset); + + public async Task 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 FairplayAsync(ulong guildId) + { + if (TryGetMusicPlayer(guildId, out var mp)) + { + mp.SetFairplay(); + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + #endregion +} diff --git a/src/EllieBot/Modules/Music/Services/extractor/Misc.cs b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs new file mode 100644 index 0000000..68c1fca --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs new file mode 100644 index 0000000..a91dd11 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs @@ -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 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>(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> 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(); + 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 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); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs new file mode 100644 index 0000000..020e074 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs new file mode 100644 index 0000000..f4ea2bf --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface ILocalTrackResolver : IPlatformQueryResolver +{ + IAsyncEnumerable ResolveDirectoryAsync(string dirPath); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs new file mode 100644 index 0000000..a593a57 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs @@ -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 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 tracks, string queuer); + void SetRepeat(PlayerRepeatType type); + void ShuffleQueue(); + void SetFairplay(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicQueue.cs b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs new file mode 100644 index 0000000..5d4d24b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs @@ -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 tracks, string queuer); + + public IReadOnlyCollection 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(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs new file mode 100644 index 0000000..fa282ed --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface IPlatformQueryResolver +{ + Task ResolveByQueryAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs new file mode 100644 index 0000000..5093cd9 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IQueuedTrackInfo : ITrackInfo +{ + public ITrackInfo TrackInfo { get; } + + public string Queuer { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IRadioResolver.cs b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs new file mode 100644 index 0000000..86a6ba5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs @@ -0,0 +1,6 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IRadioResolver : IPlatformQueryResolver +{ +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackCacher.cs b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs new file mode 100644 index 0000000..d55cd65 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackCacher +{ + Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> streamUrlFactory); + + Task CacheTrackDataAsync(ICachableTrackData data); + Task GetCachedDataByIdAsync(string id, MusicPlatform platform); + Task GetCachedDataByQueryAsync(string query, MusicPlatform platform); + Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data); + + Task CacheStreamUrlAsync( + string id, + MusicPlatform platform, + string url, + TimeSpan expiry); + + Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform); + Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids); + Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId); + Task GetPlaylistIdByQueryAsync(string query, MusicPlatform platform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackInfo.cs b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs new file mode 100644 index 0000000..3525b59 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs new file mode 100644 index 0000000..ae3d1e6 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackResolveProvider +{ + Task QuerySongAsync(string query, MusicPlatform? forcePlatform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs new file mode 100644 index 0000000..d88e51c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs @@ -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 data, int length); + public void SetGateway(VoiceGateway gateway); + Task StartSpeakingAsync(); + Task StopSpeakingAsync(); + public Task StartGateway(); + Task StopGateway(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs new file mode 100644 index 0000000..e4c2f53 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Music; + +public interface IYoutubeResolver : IPlatformQueryResolver +{ + public Task ResolveByIdAsync(string id); + IAsyncEnumerable ResolveTracksFromPlaylistAsync(string query); + Task ResolveByQueryAsync(string query, bool tryExtractingId); + Task GetStreamUrl(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs new file mode 100644 index 0000000..4e663ad --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs new file mode 100644 index 0000000..9c8a9a3 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs @@ -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 _callback; + private readonly object _state; + + public MultimediaTimer(Action 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); + } + + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// User-supplied callback data. + /// + /// Timer event type. This parameter may include one of the following values. + [DllImport("Winmm.dll")] + private static extern uint timeSetEvent( + uint uDelay, + uint uResolution, + LpTimeProcDelegate lpTimeProc, + int dwUser, + TimerMode fuEvent); + + /// + /// The timeKillEvent function cancels a specified timer event. + /// + /// + /// Identifier of the timer event to cancel. + /// This identifier was returned by the timeSetEvent function when the timer event was set up. + /// + /// Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist. + [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 + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs new file mode 100644 index 0000000..cab883b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs @@ -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 + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs new file mode 100644 index 0000000..0d6c149 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public enum MusicPlatform +{ + Radio, + Youtube, + Local, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs new file mode 100644 index 0000000..02cb4e2 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs @@ -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? OnCompleted; + public event Func? OnStarted; + public event Func? 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 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 audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(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 audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(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 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 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 data, float volume); + + public void SetFairplay() + { + _queue.ReorderFairly(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs new file mode 100644 index 0000000..9b1c7aa --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs @@ -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 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 toEnqueue, string queuer) + { + lock (_locker) + { + foreach (var track in toEnqueue) + { + var added = new QueuedTrackInfo(track, queuer); + tracks.AddLast(added); + } + } + } + + public IReadOnlyCollection 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(); + var queuers = new List>(); + + foreach (var track in tracks.Skip(index).Concat(tracks.Take(index))) + { + if (!groups.TryGetValue(track.Queuer, out var qIndex)) + { + queuers.Add(new Queue()); + qIndex = queuers.Count - 1; + groups.Add(track.Queuer, qIndex); + } + + queuers[qIndex].Enqueue(track); + } + + tracks = new LinkedList(); + 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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs new file mode 100644 index 0000000..b846b25 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs @@ -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; \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs new file mode 100644 index 0000000..c7828cc --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs new file mode 100644 index 0000000..2dcffcc --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs @@ -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 GetStreamLinkKey(MusicPlatform platform, string id) + => new($"music:stream:{platform}:{id}"); + + public async Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> 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 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 GetCachedDataByIdAsync(string id, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id)); + + + // track data by query + private TypedKey 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 GetCachedDataByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query)); + + + // playlist track ids by playlist id + private TypedKey> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform) + => new($"music:playlist_tracks:{platform}:{playlist}"); + + public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids) + => await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList()); + + public async Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform) + { + var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform)); + if (result.TryGetValue(out var val)) + return val; + + return Array.Empty(); + } + + + // playlist id by query + private TypedKey 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 GetPlaylistIdByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform)); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs new file mode 100644 index 0000000..08bb8b8 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs @@ -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 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 RunGatewayAction(Func 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs new file mode 100644 index 0000000..cca2727 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs @@ -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; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs new file mode 100644 index 0000000..fa21693 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs @@ -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 ResolveByQueryAsync(string query) + { + using var http = _httpFactory.CreateClient(); + + var items = await http.GetFromJsonAsync>( + $"{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 ResolveByIdAsync(string id) + => await InternalResolveByIdAsync(id); + + private async Task InternalResolveByIdAsync(string id) + { + using var http = _httpFactory.CreateClient(); + + var res = await http.GetFromJsonAsync( + $"{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 ResolveTracksFromPlaylistAsync(string query) + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetFromJsonAsync( + $"{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 ResolveByQueryAsync(string query, bool tryExtractingId) + => ResolveByQueryAsync(query); + + public async Task GetStreamUrl(string videoId) + { + var video = await InternalResolveByIdAsync(videoId); + return video?.StreamUrl; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs new file mode 100644 index 0000000..d728ce7 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs @@ -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 _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 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 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 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs new file mode 100644 index 0000000..475b026 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs @@ -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=(?.*?)\n", RegexOptions.Compiled); + private readonly Regex _m3URegex = new(@"(?^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline); + private readonly Regex _asxRegex = new(@".*?)""", RegexOptions.Compiled); + private readonly Regex _xspfRegex = new(@"(?.*?)", RegexOptions.Compiled); + + public async Task 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 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs new file mode 100644 index 0000000..21a6adf --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs @@ -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 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(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")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs new file mode 100644 index 0000000..869102d --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs @@ -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\/)(?[a-zA-Z0-9_-]{6,11})", + RegexOptions.Compiled); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs new file mode 100644 index 0000000..955faca --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs @@ -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(); + } + + return _services.GetRequiredService(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs new file mode 100644 index 0000000..eeb3a1c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs @@ -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\=(?\d+))"); + + + private static readonly Regex _simplePlaylistRegex = new(@"&list=(?[\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> 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 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 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(); + 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 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 ResolveByQueryAsync(string query) + => ResolveByQueryAsync(query, true); + + public async Task 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 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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs new file mode 100644 index 0000000..d8f81b5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs @@ -0,0 +1,27 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlayerSettingsExtensions +{ + public static async Task ForGuildAsync(this DbSet 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs new file mode 100644 index 0000000..697e35e --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs @@ -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 Songs { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs new file mode 100644 index 0000000..0e3e603 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs @@ -0,0 +1,18 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlaylistExtensions +{ + public static List GetPlaylistsOnPage(this DbSet 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 playlists, int id) + => playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs new file mode 100644 index 0000000..40f8397 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs @@ -0,0 +1,61 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MusicPlayerSettings +{ + /// + /// Auto generated Id + /// + public int Id { get; set; } + + /// + /// Id of the guild + /// + public ulong GuildId { get; set; } + + /// + /// Queue repeat type + /// + public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue; + + /// + /// Channel id the bot will always try to send track related messages to + /// + public ulong? MusicChannelId { get; set; } + + /// + /// Default volume player will be created with + /// + public int Volume { get; set; } = 100; + + /// + /// Whether the bot should auto disconnect from the voice channel once the queue is done + /// This only has effect if + /// + public bool AutoDisconnect { get; set; } + + /// + /// Selected quality preset for the music player + /// + public QualityPreset QualityPreset { get; set; } + + /// + /// Whether the bot will automatically queue related songs + /// + public bool AutoPlay { get; set; } +} + +public enum QualityPreset +{ + Highest, + High, + Medium, + Low +} + +public enum PlayerRepeatType +{ + None, + Track, + Queue +} \ No newline at end of file