From 31ce6ca4ae7b78603e20afee05ce7e24a0c45e5a Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Fri, 20 Jan 2023 19:13:26 +0700 Subject: [PATCH 01/25] Automatically play queueing music when the play context is stopped --- Codehard.DJ/Providers/SpotifyProvider.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Codehard.DJ/Providers/SpotifyProvider.cs b/Codehard.DJ/Providers/SpotifyProvider.cs index 29f4c96..2a4fd19 100644 --- a/Codehard.DJ/Providers/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/SpotifyProvider.cs @@ -133,6 +133,11 @@ private async void GetPlayingTrackInfoAsync(object? state) if (playingContext == null!) { + if (this._queue.Any()) + { + await this.NextAsync(); + } + return; } From e53709bf0d44caadad0a196001bc56c794223efb Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Fri, 20 Jan 2023 20:22:28 +0700 Subject: [PATCH 02/25] Add Discord Bot integration --- Codehard.DJ.sln | 6 + Codehard.DJ/Codehard.DJ.csproj | 4 + Codehard.DJ/DiscordBotHostingService.cs | 34 +++ Codehard.DJ/DjDiscordClient.cs | 107 ++++++++++ Codehard.DJ/Program.cs | 24 ++- Codehard.DJ/Providers/Models/Music.cs | 32 ++- Codehard.DJ/Providers/MusicPlayer.cs | 23 -- Codehard.DJ/Providers/SpotifyProvider.cs | 46 ++-- Codehard.DJ/TestingHostApp.cs | 49 ----- Codehard.DJ/appsettings.json | 4 + .../DiscordClientAbstract.cs | 202 ++++++++++++++++++ Infrastructure.Discord/Emojis.cs | 7 + .../Extensions/DiscordChannelExtensions.cs | 36 ++++ .../Infrastructure.Discord.csproj | 14 ++ 14 files changed, 493 insertions(+), 95 deletions(-) create mode 100644 Codehard.DJ/DiscordBotHostingService.cs create mode 100644 Codehard.DJ/DjDiscordClient.cs delete mode 100644 Codehard.DJ/Providers/MusicPlayer.cs delete mode 100644 Codehard.DJ/TestingHostApp.cs create mode 100644 Infrastructure.Discord/DiscordClientAbstract.cs create mode 100644 Infrastructure.Discord/Emojis.cs create mode 100644 Infrastructure.Discord/Extensions/DiscordChannelExtensions.cs create mode 100644 Infrastructure.Discord/Infrastructure.Discord.csproj diff --git a/Codehard.DJ.sln b/Codehard.DJ.sln index cecb170..1873473 100644 --- a/Codehard.DJ.sln +++ b/Codehard.DJ.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codehard.DJ", "Codehard.DJ\Codehard.DJ.csproj", "{BF36D43A-FEC3-41A0-B6D3-4B0E7DFE2143}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Discord", "Infrastructure.Discord\Infrastructure.Discord.csproj", "{1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {BF36D43A-FEC3-41A0-B6D3-4B0E7DFE2143}.Debug|Any CPU.Build.0 = Debug|Any CPU {BF36D43A-FEC3-41A0-B6D3-4B0E7DFE2143}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF36D43A-FEC3-41A0-B6D3-4B0E7DFE2143}.Release|Any CPU.Build.0 = Release|Any CPU + {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Codehard.DJ/Codehard.DJ.csproj b/Codehard.DJ/Codehard.DJ.csproj index 480ccf4..8f869db 100644 --- a/Codehard.DJ/Codehard.DJ.csproj +++ b/Codehard.DJ/Codehard.DJ.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/Codehard.DJ/DiscordBotHostingService.cs b/Codehard.DJ/DiscordBotHostingService.cs new file mode 100644 index 0000000..070f61a --- /dev/null +++ b/Codehard.DJ/DiscordBotHostingService.cs @@ -0,0 +1,34 @@ +using Codehard.DJ.Providers; +using Microsoft.Extensions.Hosting; + +namespace Codehard.DJ; + +public class DiscordBotHostingService : IHostedService +{ + private readonly DjDiscordClient _discordClient; + private readonly IMusicProvider _provider; + + public DiscordBotHostingService( + DjDiscordClient discordClient, + IMusicProvider provider) + { + _discordClient = discordClient; + this._provider = provider; + this._provider.PlayStartEvent += (sender, args) => { Console.WriteLine($"{args.Music.Title} started"); }; + this._provider.PlayEndEvent += (sender, args) => { Console.WriteLine($"{args.Music.Title} ended"); }; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await this._discordClient.ConnectAsync(cancellationToken); + + await Task.Delay(-1, CancellationToken.None); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + this._provider.Dispose(); + + await this._discordClient.DisposeAsync(); + } +} \ No newline at end of file diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs new file mode 100644 index 0000000..313f1f0 --- /dev/null +++ b/Codehard.DJ/DjDiscordClient.cs @@ -0,0 +1,107 @@ +using System.Text; +using Codehard.DJ.Providers; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using Infrastructure.Discord; +using Microsoft.Extensions.Logging; + +namespace Codehard.DJ; + +public class DjDiscordClient : DiscordClientAbstract +{ + private readonly ILogger _logger; + private readonly IMusicProvider _musicProvider; + + public DjDiscordClient( + string token, + IServiceProvider serviceProvider, + ILogger logger, + IMusicProvider musicProvider) + : base( + token, + new[] { "!" }, + new[] { typeof(DjCommandHandler) }, + false, + true, + serviceProvider) + { + this._logger = logger; + this._musicProvider = musicProvider; + } +} + +public class DjCommandHandler : BaseCommandModule +{ + private readonly IMusicProvider _musicProvider; + private readonly ILogger _logger; + + public DjCommandHandler( + IMusicProvider musicProvider, + ILogger logger) + { + this._musicProvider = musicProvider; + this._logger = logger; + } + + [Command("skip")] + public async Task SkipMusicAsync(CommandContext ctx) + { + await this._musicProvider.NextAsync(); + + await ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode("1F44D")); + } + + [Command("queue")] + public async Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText) + { + var music = (await this._musicProvider.SearchAsync(queryText)).ToArray(); + + if (music.Any()) + { + await this._musicProvider.EnqueueAsync(music.First()); + + await ReactAsync(ctx, Emojis.ThumbsUp); + } + else + { + await ReactAsync(ctx, Emojis.ThumbsDown); + } + } + + [Command("list-queue")] + public async Task ListQueueAsync(CommandContext ctx) + { + await ReactAsync(ctx, Emojis.ThumbsUp); + + var queue = (await this._musicProvider.GetCurrentQueueAsync()).ToArray(); + + if (!queue.Any()) + { + await ctx.RespondAsync("There is no music in queue"); + + return; + } + + var sb = new StringBuilder(); + + foreach (var music in queue) + { + sb.AppendLine(music.ToString()); + } + + var embed = new DiscordEmbedBuilder + { + Title = "Current music(s) in queue", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Green), + }; + + await ctx.RespondAsync(embed); + } + + private static Task ReactAsync(CommandContext ctx, string emojiName) + { + return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); + } +} \ No newline at end of file diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index e8a8e1f..ff7e1c7 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -6,8 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using SpotifyAPI.Web; -using SpotifyAPI.Web.Auth; +using Microsoft.Extensions.Logging; var builder = Host.CreateDefaultBuilder(args); @@ -24,8 +23,9 @@ { var configuration = context.Configuration; - var spotifyConfig = - configuration.GetSection("Configurations").GetSection("Spotify"); + var configSection = configuration.GetSection("Configurations"); + + var spotifyConfig = configSection.GetSection("Spotify"); var clientId = spotifyConfig["ClientId"]; @@ -34,7 +34,21 @@ services.TryAddSingleton(spotifyClient); services.TryAddSingleton(); - services.AddHostedService(); + var discordConfig = configSection.GetSection("Discord"); + + var discordActive = discordConfig.GetValue("Active"); + + if (discordActive) + { + services.TryAddSingleton(sp => + new DjDiscordClient( + discordConfig["Token"]!, + sp, + sp.GetRequiredService>(), + sp.GetRequiredService())); + + services.AddHostedService(); + } }); builder.UseConsoleLifetime(); diff --git a/Codehard.DJ/Providers/Models/Music.cs b/Codehard.DJ/Providers/Models/Music.cs index e0358b5..c2541ae 100644 --- a/Codehard.DJ/Providers/Models/Music.cs +++ b/Codehard.DJ/Providers/Models/Music.cs @@ -1,3 +1,33 @@ namespace Codehard.DJ.Providers.Models; -public sealed record Music(string Id, string Name, Uri? PlaySourceUri); \ No newline at end of file +public sealed record Music +{ + public Music(string id, string title, string[] artists, string album, Uri? playSourceUri) + { + this.Id = id; + this.Title = title; + this.Artists = artists; + this.Album = album; + this.PlaySourceUri = playSourceUri; + } + + public string Id { get; } + public string Title { get; } + public string[] Artists { get; } + public string Album { get; } + public Uri? PlaySourceUri { get; } + + public void Deconstruct(out string Id, out string Title, out string[] Artists, out string Album, out Uri? PlaySourceUri) + { + Id = this.Id; + Title = this.Title; + Artists = this.Artists; + Album = this.Album; + PlaySourceUri = this.PlaySourceUri; + } + + public override string ToString() + { + return $"🎵 {this.Title} - {this.Album} by {string.Join(",", this.Artists)}"; + } +} \ No newline at end of file diff --git a/Codehard.DJ/Providers/MusicPlayer.cs b/Codehard.DJ/Providers/MusicPlayer.cs deleted file mode 100644 index f6f653f..0000000 --- a/Codehard.DJ/Providers/MusicPlayer.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Codehard.DJ.Providers.Models; - -namespace Codehard.DJ.Providers; - -public sealed class MusicPlayer -{ - private readonly IMusicProvider _provider; - - public MusicPlayer(IMusicProvider provider) - { - _provider = provider; - } - - public ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default) - { - return this._provider.SearchAsync(query, cancellationToken); - } - - public ValueTask QueueAsync(Music music, CancellationToken cancellationToken = default) - { - return this._provider.EnqueueAsync(music, cancellationToken); - } -} \ No newline at end of file diff --git a/Codehard.DJ/Providers/SpotifyProvider.cs b/Codehard.DJ/Providers/SpotifyProvider.cs index 2a4fd19..9602619 100644 --- a/Codehard.DJ/Providers/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/SpotifyProvider.cs @@ -39,7 +39,12 @@ public async ValueTask> SearchAsync(string query, Cancellatio var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); return searchResponse.Tracks.Items? - .Select(item => new Music(item.Id, item.Name, new Uri(item.Uri))) + .Select(item => new Music( + item.Id, + item.Name, + item.Artists.Select(a => a.Name).ToArray(), + item.Album.Name, + new Uri(item.Uri))) ?? Enumerable.Empty(); } @@ -129,29 +134,36 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) private async void GetPlayingTrackInfoAsync(object? state) { - var playingContext = await this._client.Player.GetCurrentPlayback(); - - if (playingContext == null!) + try { - if (this._queue.Any()) + var playingContext = await this._client.Player.GetCurrentPlayback(); + + if (playingContext == null!) { - await this.NextAsync(); - } + if (this._queue.Any()) + { + await this.NextAsync(); + } - return; - } + return; + } - if (playingContext.Item is not FullTrack currentTrack) - { - return; - } + if (playingContext.Item is not FullTrack currentTrack) + { + return; + } - var trackEnded = playingContext.ProgressMs >= currentTrack.DurationMs; - var trackStopped = !playingContext.IsPlaying && playingContext.ProgressMs == 0 && this._queue.Any(); + var trackEnded = playingContext.ProgressMs >= currentTrack.DurationMs; + var trackStopped = !playingContext.IsPlaying && playingContext.ProgressMs == 0 && this._queue.Any(); - if (trackEnded || trackStopped) + if (trackEnded || trackStopped) + { + await this.NextAsync(); + } + } + catch (APIException ex) { - await this.NextAsync(); + this._logger.LogError(ex, "An error occurred during track info gathering"); } } diff --git a/Codehard.DJ/TestingHostApp.cs b/Codehard.DJ/TestingHostApp.cs deleted file mode 100644 index 74b19f4..0000000 --- a/Codehard.DJ/TestingHostApp.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Codehard.DJ.Providers; -using Microsoft.Extensions.Hosting; - -namespace Codehard.DJ; - -public class TestingHostApp : IHostedService -{ - private readonly IMusicProvider _provider; - - public TestingHostApp(IMusicProvider provider) - { - this._provider = provider; - this._provider.PlayStartEvent += (sender, args) => { Console.WriteLine($"{args.Music.Name} started"); }; - this._provider.PlayEndEvent += (sender, args) => { Console.WriteLine($"{args.Music.Name} ended"); }; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - while (true) - { - Console.Write("Search keyword: "); - - var keyword = Console.ReadLine(); - - if (string.IsNullOrWhiteSpace(keyword)) - { - continue; - } - - var searchResult = await this._provider.SearchAsync(keyword, cancellationToken); - - var music = searchResult.FirstOrDefault(); - - if (music == null) - { - continue; - } - - await this._provider.EnqueueAsync(music, cancellationToken); - } - } - - public Task StopAsync(CancellationToken cancellationToken) - { - this._provider.Dispose(); - - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index e15f043..fcdc819 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -2,6 +2,10 @@ "Configurations": { "Spotify": { "ClientId": "dbd6f23048e64779862538ff17bfa402" + }, + "Discord": { + "Active": false, + "Token": "" } }, "Logging": { diff --git a/Infrastructure.Discord/DiscordClientAbstract.cs b/Infrastructure.Discord/DiscordClientAbstract.cs new file mode 100644 index 0000000..97d0b3d --- /dev/null +++ b/Infrastructure.Discord/DiscordClientAbstract.cs @@ -0,0 +1,202 @@ +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace Infrastructure.Discord; + +public abstract class DiscordClientAbstract : IAsyncDisposable +{ + protected readonly DiscordClient Client; + + private readonly List _channels; + + private readonly List _guilds; + + private readonly List _members; + + public virtual IReadOnlyList Channels => this._channels; + + public virtual IReadOnlyList Guilds => this._guilds; + + public virtual IReadOnlyList Members => this._members; + + protected DiscordClientAbstract(string token) + { + this._channels = new List(); + this._guilds = new List(); + this._members = new List(); + + this.Client = new DiscordClient(new DiscordConfiguration() + { + Token = token, + TokenType = TokenType.Bot, + AutoReconnect = true, + Intents = DiscordIntents.All + }); + + this.Client.GuildDownloadCompleted += DiscordClient_GuildDownloadCompleted; + this.Client.GuildCreated += DiscordClient_GuildCreatedCompleted; + this.Client.GuildDeleted += DiscordClient_GuildDeletedCompleted; + this.Client.ChannelCreated += DiscordClient_ChannelCreated; + this.Client.ChannelDeleted += DiscordClient_ChannelDeleted; + this.Client.GuildMemberAdded += Client_GuildMemberAdded; + this.Client.GuildMemberRemoved += Client_GuildMemberRemoved; + } + + protected DiscordClientAbstract( + string token, + string[] commandPrefixes, + IEnumerable commandModules, + bool allowDms = false, + bool allowMentionPrefix = false, + IServiceProvider? serviceProvider = default) + : this(token) + { + var command = this.Client.UseCommandsNext(new CommandsNextConfiguration + { + StringPrefixes = commandPrefixes, + EnableDms = allowDms, + EnableMentionPrefix = allowMentionPrefix, + Services = serviceProvider, + }); + + foreach (var module in commandModules) + { + if (!module.IsAssignableTo(typeof(BaseCommandModule))) + { + throw new Exception($"Type {module.FullName} is not a {nameof(BaseCommandModule)}"); + } + + command.RegisterCommands(module); + } + } + + private Task Client_GuildMemberRemoved(DiscordClient sender, GuildMemberRemoveEventArgs e) + { + lock (this._members) + { + this._members.Remove(e.Member); + + return Task.CompletedTask; + } + } + + private Task Client_GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + lock (this._members) + { + this._members.Add(e.Member); + + return Task.CompletedTask; + } + } + + public Task ConnectAsync(CancellationToken cancellation = default) + { + return this.Client.ConnectAsync(); + } + + protected virtual Task DiscordClient_ChannelDeleted(DiscordClient sender, ChannelDeleteEventArgs e) + { + lock (this._channels) + { + var channel = e.Channel; + + var existingChannel = this._channels.SingleOrDefault(x => x.Id == channel.Id); + + if (existingChannel != null) + { + this._channels.Remove(existingChannel); + } + + return Task.CompletedTask; + } + } + + protected virtual Task DiscordClient_ChannelCreated(DiscordClient sender, ChannelCreateEventArgs e) + { + lock (this._channels) + { + var channel = e.Channel; + + if (channel.Type == ChannelType.Text) + { + this._channels.Add(channel); + } + + return Task.CompletedTask; + } + } + + protected virtual Task DiscordClient_GuildDeletedCompleted(DiscordClient sender, GuildDeleteEventArgs e) + { + var guild = e.Guild; + + lock (this._channels) + this._channels.RemoveAll(x => x.GuildId == guild.Id); + + lock (this._members) + this._members.RemoveAll(x => x.Guild.Id == guild.Id); + + lock (this._guilds) + this._guilds.Remove(guild); + + return Task.CompletedTask; + } + + protected virtual Task DiscordClient_GuildCreatedCompleted(DiscordClient sender, GuildCreateEventArgs e) + { + var guild = e.Guild; + + var channels = guild.Channels.Select(x => x.Value); + + lock (this._channels) + this._channels.AddRange(channels); + + lock (this._members) + this._members.AddRange(guild.Members.Select(x => x.Value)); + + lock (this._guilds) + this._guilds.Add(guild); + + return Task.CompletedTask; + } + + protected virtual Task DiscordClient_GuildDownloadCompleted(DiscordClient sender, GuildDownloadCompletedEventArgs e) + { + var guilds = e.Guilds.Select(x => x.Value); + + foreach (var guild in guilds) + { + var channels = guild.Channels.Select(x => x.Value); + + lock (this._channels) + this._channels.AddRange(channels); + + lock (this._members) + this._members.AddRange(guild.Members.Select(x => x.Value)); + + lock (this._guilds) + this._guilds.Add(guild); + } + + return Task.CompletedTask; + } + + public IEnumerable GetDiscordChannels() + { + lock (this._channels) + { + foreach (var ch in this._channels) + { + yield return ch; + } + } + } + + public async ValueTask DisposeAsync() + { + await this.Client.DisconnectAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure.Discord/Emojis.cs b/Infrastructure.Discord/Emojis.cs new file mode 100644 index 0000000..f736026 --- /dev/null +++ b/Infrastructure.Discord/Emojis.cs @@ -0,0 +1,7 @@ +namespace Infrastructure.Discord; + +public static class Emojis +{ + public const string ThumbsUp = ":thumbsup:"; + public const string ThumbsDown = ":thumbsdown:"; +} \ No newline at end of file diff --git a/Infrastructure.Discord/Extensions/DiscordChannelExtensions.cs b/Infrastructure.Discord/Extensions/DiscordChannelExtensions.cs new file mode 100644 index 0000000..9ed257c --- /dev/null +++ b/Infrastructure.Discord/Extensions/DiscordChannelExtensions.cs @@ -0,0 +1,36 @@ +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; + +namespace Infrastructure.Discord.Extensions; + +public static class DiscordChannelExtensions +{ + /// + /// Send message that will automatically delete after timeout period. + /// + /// + /// + /// + /// + public static async Task SendDisposableMessageAsync(this CommandContext commandContext, string message, int deleteInMs = 10000, CancellationToken cancellationToken = default) + { + var sentMessage = await commandContext.RespondAsync(message); + + _ = Task.Delay(deleteInMs) + .ContinueWith(async (arg) => { await sentMessage.DeleteAsync(); }); + } + + /// + /// Send message that will automatically delete after timeout period. + /// + /// + /// + /// + /// + public static async Task SendDisposableMessageAsync(this DiscordChannel channel, string message, int deleteInMs = 10000, CancellationToken cancellationToken = default) + { + var sentMessage = await channel.SendMessageAsync(message); + _ = Task.Delay(deleteInMs) + .ContinueWith(async (arg) => { await sentMessage.DeleteAsync(); }); + } +} \ No newline at end of file diff --git a/Infrastructure.Discord/Infrastructure.Discord.csproj b/Infrastructure.Discord/Infrastructure.Discord.csproj new file mode 100644 index 0000000..8ca3dd1 --- /dev/null +++ b/Infrastructure.Discord/Infrastructure.Discord.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + + + + + + + + From 793fe41115509ef0351d1e609e8ec9b4440c65fa Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Mon, 23 Jan 2023 20:49:30 +0700 Subject: [PATCH 03/25] Fix spotify client access token expired periodically --- Codehard.DJ/Bootstrap.cs | 31 ++++++++++++++++++------ Codehard.DJ/DjDiscordClient.cs | 11 ++++++++- Codehard.DJ/Program.cs | 4 ++- Codehard.DJ/Providers/SpotifyProvider.cs | 2 +- Codehard.DJ/appsettings.json | 3 ++- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Codehard.DJ/Bootstrap.cs b/Codehard.DJ/Bootstrap.cs index 288e6a7..51a4b92 100644 --- a/Codehard.DJ/Bootstrap.cs +++ b/Codehard.DJ/Bootstrap.cs @@ -5,22 +5,37 @@ namespace Codehard.DJ; public static class Bootstrap { - public static async Task InitializeSpotifyClientAsync(string clientId, CancellationToken cancellationToken = default) + public static async Task InitializeSpotifyClientAsync( + string clientId, + string clientSecret, + CancellationToken cancellationToken = default) { - var server = new EmbedIOAuthServer(new Uri("http://localhost:8800/callback"), 8800); + var callbackUri = new Uri("http://localhost:8800/callback"); + + var server = new EmbedIOAuthServer(callbackUri, 8800); await server.Start(); - string? accessToken = default; + SpotifyClient? client = default; - server.ImplictGrantReceived += async (sender, response) => + server.AuthorizationCodeReceived += async (sender, response) => { var server = (EmbedIOAuthServer)sender; await server.Stop(); - accessToken = response.AccessToken; + var tokenResponse = await new OAuthClient() + .RequestToken( + new AuthorizationCodeTokenRequest(clientId, clientSecret, response.Code, callbackUri), + cancellationToken); + + var config = SpotifyClientConfig + .CreateDefault() + .WithAuthenticator(new AuthorizationCodeAuthenticator("ClientId", "ClientSecret", tokenResponse)); + + client = new SpotifyClient(tokenResponse.AccessToken); }; + server.ErrorReceived += async (sender, error, state) => { Console.WriteLine($"Aborting authorization, error received: {error}"); @@ -33,7 +48,7 @@ public static async Task InitializeSpotifyClientAsync(string clie var request = new LoginRequest( server.BaseUri, clientId, - LoginRequest.ResponseType.Token) + LoginRequest.ResponseType.Code) { Scope = new List { @@ -51,9 +66,9 @@ public static async Task InitializeSpotifyClientAsync(string clie { try { - if (!string.IsNullOrWhiteSpace(accessToken)) + if (client != null) { - return new SpotifyClient(accessToken); + return client; } await Task.Delay(300, cancellationToken).ConfigureAwait(false); diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 313f1f0..4f53b8d 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -28,6 +28,15 @@ public DjDiscordClient( { this._logger = logger; this._musicProvider = musicProvider; + + this._musicProvider.PlayStartEvent += async (sender, args) => + { + await this.Client.UpdateStatusAsync(new DiscordActivity + { + Name = $"{args.Music.Title} - {args.Music.Album} by {string.Join(", ", args.Music.Artists)}", + ActivityType = ActivityType.ListeningTo, + }); + }; } } @@ -49,7 +58,7 @@ public async Task SkipMusicAsync(CommandContext ctx) { await this._musicProvider.NextAsync(); - await ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode("1F44D")); + await ReactAsync(ctx, Emojis.ThumbsUp); } [Command("queue")] diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index ff7e1c7..f24c319 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -28,8 +28,10 @@ var spotifyConfig = configSection.GetSection("Spotify"); var clientId = spotifyConfig["ClientId"]; + var clientSecret = spotifyConfig["ClientSecret"]; + var cancelToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token; - var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!).Result; + var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!, clientSecret!, cancelToken).Result; services.TryAddSingleton(spotifyClient); services.TryAddSingleton(); diff --git a/Codehard.DJ/Providers/SpotifyProvider.cs b/Codehard.DJ/Providers/SpotifyProvider.cs index 9602619..f41fa57 100644 --- a/Codehard.DJ/Providers/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/SpotifyProvider.cs @@ -27,7 +27,7 @@ public SpotifyProvider( // so we doing the queue in memory this._queue = new Queue(); this._playedStack = new Stack(); - this._timer = new Timer(GetPlayingTrackInfoAsync, default, TimeSpan.Zero, TimeSpan.FromMilliseconds(300)); + this._timer = new Timer(GetPlayingTrackInfoAsync, default, TimeSpan.Zero, TimeSpan.FromSeconds(3)); } public event PlayStartEventHandler? PlayStartEvent; diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index fcdc819..39eb1c8 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -1,7 +1,8 @@ { "Configurations": { "Spotify": { - "ClientId": "dbd6f23048e64779862538ff17bfa402" + "ClientId": "dbd6f23048e64779862538ff17bfa402", + "ClientSecret": "7df3f17b8240433c9cddc8f92cf6cf6e" }, "Discord": { "Active": false, From cbcaf5b39ee2366db9e68f262c2c880015844bcc Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Mon, 23 Jan 2023 21:09:15 +0700 Subject: [PATCH 04/25] Add search function --- Codehard.DJ/DjDiscordClient.cs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 4f53b8d..41f6dbe 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -61,7 +61,7 @@ public async Task SkipMusicAsync(CommandContext ctx) await ReactAsync(ctx, Emojis.ThumbsUp); } - [Command("queue")] + [Command("q")] public async Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText) { var music = (await this._musicProvider.SearchAsync(queryText)).ToArray(); @@ -78,7 +78,7 @@ public async Task QueueMusicAsync(CommandContext ctx, [RemainingText] string que } } - [Command("list-queue")] + [Command("list-q")] public async Task ListQueueAsync(CommandContext ctx) { await ReactAsync(ctx, Emojis.ThumbsUp); @@ -109,6 +109,35 @@ public async Task ListQueueAsync(CommandContext ctx) await ctx.RespondAsync(embed); } + [Command("search")] + public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryText) + { + var searchResult = (await this._musicProvider.SearchAsync(queryText)).ToArray(); + + if (!searchResult.Any()) + { + await ctx.RespondAsync("There is music matching your search text."); + + return; + } + + var sb = new StringBuilder(); + + foreach (var music in searchResult) + { + sb.AppendLine($"{music.Title} {music.Album} {string.Join(", ", music.Artists)}"); + } + + var embed = new DiscordEmbedBuilder + { + Title = $"Search result for {queryText}", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Blue), + }; + + await ctx.RespondAsync(embed); + } + private static Task ReactAsync(CommandContext ctx, string emojiName) { return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); From 612c54c1c3ac2dad05af9446a8b2f99d393def3f Mon Sep 17 00:00:00 2001 From: Deszolate_C Date: Mon, 23 Jan 2023 21:44:10 +0700 Subject: [PATCH 05/25] Git workflows (#1) * git workflows build * merge to main Co-authored-by: chinnawat intarasura --- .github/workflows/build.yml | 37 ++++++++++++++++++++++++++++++++++++ .github/workflows/dotnet.yml | 23 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/dotnet.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..212a64d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: merge to main + +on: + push: + branches: [ main ] + +jobs: + build_dotnet_core: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 7.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal + - name: Publish ubuntu 20.04 x64 + run: dotnet publish -c Release -r ubuntu.20.04-x64 + - name: Publish windows 10 x64 + run: dotnet publish -c Release -r win10-x64 + - name: Upload ubuntu build artifact + uses: actions/upload-artifact@v2 + with: + name: DJ_ubuntu_2004_x64 + path: /home/runner/work/DJ/DJ/Codehard.DJ/bin/Release/net7.0/ubuntu.20.04-x64/publish/ + - name: Upload windows build artifact + uses: actions/upload-artifact@v2 + with: + name: DJ_win10_x64 + path: /home/runner/work/DJ/DJ/Codehard.DJ/bin/Release/net7.0/win10-x64/publish/ \ No newline at end of file diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..43e710c --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,23 @@ +name: dotnet core pull request continuous integration + +on: + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 7.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal From 57008d5d12415d4b0a0dadd792302b781e76e6ce Mon Sep 17 00:00:00 2001 From: JameHub <105290490+LittleBearXii@users.noreply.github.com> Date: Mon, 23 Jan 2023 22:08:40 +0700 Subject: [PATCH 06/25] Add logging (#2) --- Codehard.DJ/DiscordBotHostingService.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Codehard.DJ/DiscordBotHostingService.cs b/Codehard.DJ/DiscordBotHostingService.cs index 070f61a..f422258 100644 --- a/Codehard.DJ/DiscordBotHostingService.cs +++ b/Codehard.DJ/DiscordBotHostingService.cs @@ -1,5 +1,6 @@ using Codehard.DJ.Providers; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Codehard.DJ; @@ -7,15 +8,18 @@ public class DiscordBotHostingService : IHostedService { private readonly DjDiscordClient _discordClient; private readonly IMusicProvider _provider; + private readonly ILogger _logger; public DiscordBotHostingService( DjDiscordClient discordClient, - IMusicProvider provider) + IMusicProvider provider, + ILogger logger) { - _discordClient = discordClient; + this._discordClient = discordClient; + this._logger = logger; this._provider = provider; - this._provider.PlayStartEvent += (sender, args) => { Console.WriteLine($"{args.Music.Title} started"); }; - this._provider.PlayEndEvent += (sender, args) => { Console.WriteLine($"{args.Music.Title} ended"); }; + this._provider.PlayStartEvent += (sender, args) => { this._logger.LogInformation($"{args.Music.Title} started"); }; + this._provider.PlayEndEvent += (sender, args) => { this._logger.LogInformation($"{args.Music.Title} ended"); }; } public async Task StartAsync(CancellationToken cancellationToken) From 706991223dd516e60d9c8fc0919c7ecc57d99463 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Mon, 23 Jan 2023 22:36:37 +0700 Subject: [PATCH 07/25] YOLO --- Codehard.DJ/Bootstrap.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Codehard.DJ/Bootstrap.cs b/Codehard.DJ/Bootstrap.cs index 51a4b92..1a150a6 100644 --- a/Codehard.DJ/Bootstrap.cs +++ b/Codehard.DJ/Bootstrap.cs @@ -31,9 +31,9 @@ public static async Task InitializeSpotifyClientAsync( var config = SpotifyClientConfig .CreateDefault() - .WithAuthenticator(new AuthorizationCodeAuthenticator("ClientId", "ClientSecret", tokenResponse)); + .WithAuthenticator(new AuthorizationCodeAuthenticator(clientId, clientSecret, tokenResponse)); - client = new SpotifyClient(tokenResponse.AccessToken); + client = new SpotifyClient(config); }; server.ErrorReceived += async (sender, error, state) => From 351a0f35c0f92e86c468d6cc6707b9f92c106c02 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Mon, 23 Jan 2023 22:39:07 +0700 Subject: [PATCH 08/25] Limit queue listing to maximum 10 items --- Codehard.DJ/DjDiscordClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 41f6dbe..dd0e7f7 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -83,7 +83,7 @@ public async Task ListQueueAsync(CommandContext ctx) { await ReactAsync(ctx, Emojis.ThumbsUp); - var queue = (await this._musicProvider.GetCurrentQueueAsync()).ToArray(); + var queue = (await this._musicProvider.GetCurrentQueueAsync()).Take(10).ToArray(); if (!queue.Any()) { @@ -101,7 +101,7 @@ public async Task ListQueueAsync(CommandContext ctx) var embed = new DiscordEmbedBuilder { - Title = "Current music(s) in queue", + Title = $"Current top {queue.Length} music(s) in queue", Description = sb.ToString(), Color = new Optional(DiscordColor.Green), }; From 354ae4f574c924565bea2f9103b06fb7c4db25df Mon Sep 17 00:00:00 2001 From: Deszolate_C Date: Tue, 24 Jan 2023 16:48:54 +0700 Subject: [PATCH 09/25] Substring presence to 128 characters --- Codehard.DJ/DjDiscordClient.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index dd0e7f7..64cc889 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -31,9 +31,11 @@ public DjDiscordClient( this._musicProvider.PlayStartEvent += async (sender, args) => { + var name = $"{args.Music.Title} - {args.Music.Album} by {string.Join(", ", args.Music.Artists)}"; + await this.Client.UpdateStatusAsync(new DiscordActivity { - Name = $"{args.Music.Title} - {args.Music.Album} by {string.Join(", ", args.Music.Artists)}", + Name = name[..128], ActivityType = ActivityType.ListeningTo, }); }; From 7295db6c7da80b0f8a5ff05584c1b704077ffc23 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Wed, 25 Jan 2023 21:50:33 +0700 Subject: [PATCH 10/25] Add throttle to queue and skip command --- Codehard.DJ/Codehard.DJ.csproj | 1 + Codehard.DJ/DjDiscordClient.cs | 165 ++++++++++++++++++++--- Codehard.DJ/Program.cs | 2 +- Codehard.DJ/Providers/IMusicProvider.cs | 8 +- Codehard.DJ/Providers/Models/Music.cs | 23 ++-- Codehard.DJ/Providers/SpotifyProvider.cs | 24 ++-- 6 files changed, 185 insertions(+), 38 deletions(-) diff --git a/Codehard.DJ/Codehard.DJ.csproj b/Codehard.DJ/Codehard.DJ.csproj index 8f869db..082ad72 100644 --- a/Codehard.DJ/Codehard.DJ.csproj +++ b/Codehard.DJ/Codehard.DJ.csproj @@ -12,6 +12,7 @@ + diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index bd808ed..f943b1e 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Runtime.Caching; +using System.Text; using Codehard.DJ.Providers; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; @@ -39,11 +40,26 @@ await this.Client.UpdateStatusAsync(new DiscordActivity ActivityType = ActivityType.ListeningTo, }); }; + + this._musicProvider.PlayEndEvent += async (_, _) => + { + if (this._musicProvider.RemainingInQueue == 0) + { + await this.Client.UpdateStatusAsync(new DiscordActivity + { + Name = "Sleeping...", + ActivityType = ActivityType.Custom, + }); + } + }; } } public class DjCommandHandler : BaseCommandModule { + private const string CacheName = "CommandCache"; + + private readonly MemoryCache _cache = new(CacheName); private readonly IMusicProvider _musicProvider; private readonly ILogger _logger; @@ -56,28 +72,81 @@ public DjCommandHandler( } [Command("skip")] - public async Task SkipMusicAsync(CommandContext ctx) + public Task SkipMusicAsync(CommandContext ctx) { - await this._musicProvider.NextAsync(); + return PerformWithThrottlePolicy(ctx, m => $"skip-{m.Id}", async (context, member) => + { + if (!IsCurrentSongOwner(member)) + { + await ReactAsync(context, Emojis.ThumbsDown); + await context.RespondAsync("Please respect others rights!"); - await ReactAsync(ctx, Emojis.ThumbsUp); + return; + } + + await this._musicProvider.NextAsync(); + + await ReactAsync(context, Emojis.ThumbsUp); + }); } [Command("q")] - public async Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText) + public Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText) { - var music = (await this._musicProvider.SearchAsync(queryText)).ToArray(); - - if (music.Any()) + return PerformWithThrottlePolicy(ctx, m => $"queue-{m.Id}", async (context, member) => { - await this._musicProvider.EnqueueAsync(music.First()); + var musics = (await this._musicProvider.SearchAsync(queryText)).ToArray(); - await ReactAsync(ctx, Emojis.ThumbsUp); - } - else - { - await ReactAsync(ctx, Emojis.ThumbsDown); - } + if (musics.Any()) + { + var music = musics.First(); + + this._cache.Add( + music.RandomIdentifier.ToString(), + member.Id, + DateTimeOffset.UtcNow.AddMinutes(5)); + + await this._musicProvider.EnqueueAsync(music); + + await ReactAsync(context, Emojis.ThumbsUp); + + var queue = await this._musicProvider.GetCurrentQueueAsync(); + + var prevs = queue.TakeLast(3).ToArray(); + + if (!prevs.Any()) + { + return; + } + + var totalInQueue = this._musicProvider.RemainingInQueue; + + if (totalInQueue > 1) + { + await context.RespondAsync($"{totalInQueue - 1} music(s) ahead in queue"); + } + + var sb = new StringBuilder(); + + foreach (var m in prevs) + { + sb.AppendLine($"- {m.Title} {m.Album} {string.Join(", ", m.Artists)}"); + } + + var embed = new DiscordEmbedBuilder + { + Title = $"The last {prevs.Length} music(s) in queue are", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Blue), + }; + + await ctx.RespondAsync(embed); + } + else + { + await ReactAsync(context, Emojis.ThumbsDown); + } + }); } [Command("list-q")] @@ -140,8 +209,74 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe await ctx.RespondAsync(embed); } + private async Task PerformWithThrottlePolicy( + CommandContext context, + Func keyFunc, + Func func) + { + if (!TryGetMember(context, out var member)) + { + await ReactAsync(context, Emojis.ThumbsDown); + + return; + } + + var key = keyFunc(member); + + if (this.TryGetCache(key, out DateTimeOffset expirationDateTimeOffset)) + { + await ReactAsync(context, Emojis.ThumbsDown); + + await context.RespondAsync( + $"You're being throttle, " + + $"please try again in {(int)expirationDateTimeOffset.Subtract(DateTimeOffset.UtcNow).TotalSeconds} second(s)."); + + return; + } + + var expireTime = DateTimeOffset.UtcNow.AddMinutes(3); + + await func(context, member); + + this._cache.Add(key, expireTime, new CacheItemPolicy + { + AbsoluteExpiration = expireTime, + }); + } + + private bool IsCurrentSongOwner(DiscordMember member) + { + var current = this._musicProvider.Current; + + if (current == null || !TryGetCache(current.RandomIdentifier.ToString(), out ulong memberId)) + { + return false; + } + + return member.Id == memberId; + } + + private bool TryGetCache(string key, out T value) + { + if (!this._cache.Contains(key)) + { + value = default!; + + return false; + } + + value = (T)this._cache[key]; + + return true; + } + private static Task ReactAsync(CommandContext ctx, string emojiName) { return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); } + + private static bool TryGetMember(CommandContext context, out DiscordMember member) + { + return (member = context.Member!) != null; + } } \ No newline at end of file diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index f24c319..2916a21 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -29,7 +29,7 @@ var clientId = spotifyConfig["ClientId"]; var clientSecret = spotifyConfig["ClientSecret"]; - var cancelToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token; + var cancelToken = new CancellationTokenSource().Token; var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!, clientSecret!, cancelToken).Result; diff --git a/Codehard.DJ/Providers/IMusicProvider.cs b/Codehard.DJ/Providers/IMusicProvider.cs index f835203..5d68234 100644 --- a/Codehard.DJ/Providers/IMusicProvider.cs +++ b/Codehard.DJ/Providers/IMusicProvider.cs @@ -7,9 +7,9 @@ public sealed class MusicPlayerEventArgs : EventArgs public Music Music { get; init; } } -public delegate void PlayStartEventHandler(object sender, MusicPlayerEventArgs args); +public delegate void PlayStartEventHandler(IMusicProvider sender, MusicPlayerEventArgs args); -public delegate void PlayEndEventHandler(object sender, MusicPlayerEventArgs args); +public delegate void PlayEndEventHandler(IMusicProvider sender, MusicPlayerEventArgs args); public interface IMusicProvider : IDisposable { @@ -17,6 +17,10 @@ public interface IMusicProvider : IDisposable event PlayEndEventHandler PlayEndEvent; + Music? Current { get; } + + int RemainingInQueue { get; } + ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default); ValueTask> GetCurrentQueueAsync(CancellationToken cancellationToken = default); diff --git a/Codehard.DJ/Providers/Models/Music.cs b/Codehard.DJ/Providers/Models/Music.cs index c2541ae..ea8c601 100644 --- a/Codehard.DJ/Providers/Models/Music.cs +++ b/Codehard.DJ/Providers/Models/Music.cs @@ -2,32 +2,35 @@ public sealed record Music { - public Music(string id, string title, string[] artists, string album, Uri? playSourceUri) + public Music( + string id, + string title, + string[] artists, + string album, + Uri? playSourceUri) { this.Id = id; this.Title = title; this.Artists = artists; this.Album = album; this.PlaySourceUri = playSourceUri; + this.RandomIdentifier = Guid.NewGuid(); } public string Id { get; } + public string Title { get; } + public string[] Artists { get; } + public string Album { get; } + public Uri? PlaySourceUri { get; } - public void Deconstruct(out string Id, out string Title, out string[] Artists, out string Album, out Uri? PlaySourceUri) - { - Id = this.Id; - Title = this.Title; - Artists = this.Artists; - Album = this.Album; - PlaySourceUri = this.PlaySourceUri; - } + public Guid RandomIdentifier { get; } public override string ToString() { - return $"🎵 {this.Title} - {this.Album} by {string.Join(",", this.Artists)}"; + return $"🎵 {this.Title} - {this.Album} by {string.Join(", ", this.Artists)}"; } } \ No newline at end of file diff --git a/Codehard.DJ/Providers/SpotifyProvider.cs b/Codehard.DJ/Providers/SpotifyProvider.cs index f41fa57..21a0f5e 100644 --- a/Codehard.DJ/Providers/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/SpotifyProvider.cs @@ -34,6 +34,10 @@ public SpotifyProvider( public event PlayEndEventHandler? PlayEndEvent; + public Music? Current => this._currentMusic; + + public int RemainingInQueue => this._queue.Count; + public async ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default) { var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); @@ -90,16 +94,6 @@ await this._client.Player.ResumePlayback( DeviceId = device.Id, }, cancellationToken); - if (this._currentMusic != null) - { - this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs - { - Music = this._currentMusic!, - }); - - this._playedStack.Push(this._currentMusic); - } - this._currentMusic = music; this.PlayStartEvent?.Invoke(this, new MusicPlayerEventArgs @@ -158,6 +152,16 @@ private async void GetPlayingTrackInfoAsync(object? state) if (trackEnded || trackStopped) { + if (this._currentMusic != null) + { + this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs + { + Music = this._currentMusic!, + }); + + this._playedStack.Push(this._currentMusic); + } + await this.NextAsync(); } } From 64bbf2b064100d1c8180158151d0274fde21b88f Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Wed, 25 Jan 2023 21:55:09 +0700 Subject: [PATCH 11/25] Remove cancellation token during bootstraping step --- Codehard.DJ/Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index 2916a21..d33bbb1 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -29,9 +29,8 @@ var clientId = spotifyConfig["ClientId"]; var clientSecret = spotifyConfig["ClientSecret"]; - var cancelToken = new CancellationTokenSource().Token; - var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!, clientSecret!, cancelToken).Result; + var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!, clientSecret!).Result; services.TryAddSingleton(spotifyClient); services.TryAddSingleton(); From 5b2284342794d529ae11ae2c5a69b38105b9afce Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Thu, 26 Jan 2023 00:07:14 +0700 Subject: [PATCH 12/25] Refactor GetPlayingTrackInfoAsync to be more readable --- Codehard.DJ/DjDiscordClient.cs | 4 +- Codehard.DJ/Program.cs | 1 + .../Providers/Spotify/PlaybackState.cs | 8 ++ .../{ => Spotify}/SpotifyProvider.cs | 78 +++++++++++++------ 4 files changed, 64 insertions(+), 27 deletions(-) create mode 100644 Codehard.DJ/Providers/Spotify/PlaybackState.cs rename Codehard.DJ/Providers/{ => Spotify}/SpotifyProvider.cs (76%) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index f943b1e..2b94b9c 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -47,8 +47,8 @@ await this.Client.UpdateStatusAsync(new DiscordActivity { await this.Client.UpdateStatusAsync(new DiscordActivity { - Name = "Sleeping...", - ActivityType = ActivityType.Custom, + Name = "the empty queue", + ActivityType = ActivityType.Watching, }); } }; diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index d33bbb1..1cdd518 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -2,6 +2,7 @@ using Codehard.DJ; using Codehard.DJ.Providers; +using Codehard.DJ.Providers.Spotify; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/Codehard.DJ/Providers/Spotify/PlaybackState.cs b/Codehard.DJ/Providers/Spotify/PlaybackState.cs new file mode 100644 index 0000000..232eda6 --- /dev/null +++ b/Codehard.DJ/Providers/Spotify/PlaybackState.cs @@ -0,0 +1,8 @@ +namespace Codehard.DJ.Providers.Spotify; + +public enum PlaybackState +{ + Playing, + Ended, + Stopped, +} \ No newline at end of file diff --git a/Codehard.DJ/Providers/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs similarity index 76% rename from Codehard.DJ/Providers/SpotifyProvider.cs rename to Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 21a0f5e..01c47d5 100644 --- a/Codehard.DJ/Providers/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using SpotifyAPI.Web; -namespace Codehard.DJ.Providers; +namespace Codehard.DJ.Providers.Spotify; public class SpotifyProvider : IMusicProvider { @@ -130,45 +130,73 @@ private async void GetPlayingTrackInfoAsync(object? state) { try { - var playingContext = await this._client.Player.GetCurrentPlayback(); + var playbackState = await IsCurrentPlaybackEndedAsync(); - if (playingContext == null!) + switch (playbackState) { - if (this._queue.Any()) - { - await this.NextAsync(); - } + case PlaybackState.Playing: + return; + case PlaybackState.Ended: + case PlaybackState.Stopped: + if (this._currentMusic != null) + { + this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs + { + Music = this._currentMusic!, + }); - return; - } + this._playedStack.Push(this._currentMusic); - if (playingContext.Item is not FullTrack currentTrack) - { - return; - } + this._currentMusic = null; + } - var trackEnded = playingContext.ProgressMs >= currentTrack.DurationMs; - var trackStopped = !playingContext.IsPlaying && playingContext.ProgressMs == 0 && this._queue.Any(); + if (this._queue.Any()) + { + await this.NextAsync(); + } + + break; + default: + throw new NotSupportedException(); + } - if (trackEnded || trackStopped) + if (this._currentMusic != null) { - if (this._currentMusic != null) + this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs { - this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs - { - Music = this._currentMusic!, - }); - - this._playedStack.Push(this._currentMusic); - } + Music = this._currentMusic!, + }); - await this.NextAsync(); + this._playedStack.Push(this._currentMusic); } } catch (APIException ex) { this._logger.LogError(ex, "An error occurred during track info gathering"); } + + async Task IsCurrentPlaybackEndedAsync() + { + var playingContext = await this._client.Player.GetCurrentPlayback(); + + if (playingContext == null!) + { + return PlaybackState.Stopped; + } + + if (playingContext.Item is not FullTrack currentTrack) + { + return PlaybackState.Stopped; + } + + var trackEnded = playingContext.ProgressMs >= currentTrack.DurationMs; + var trackStopped = !playingContext.IsPlaying && playingContext.ProgressMs == 0; + + return + trackEnded ? PlaybackState.Ended : + trackStopped ? PlaybackState.Stopped : + PlaybackState.Playing; + } } protected void Dispose(bool disposing) From eee19ddc620686e2e1c8b5cf0bdce1f75019e8a0 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Thu, 26 Jan 2023 00:38:15 +0700 Subject: [PATCH 13/25] Fix presence to update more deterministic --- Codehard.DJ/DjDiscordClient.cs | 4 ++-- Codehard.DJ/Providers/IMusicProvider.cs | 3 +++ .../Providers/Spotify/SpotifyProvider.cs | 18 ++++++------------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 2b94b9c..faa2d64 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -41,9 +41,9 @@ await this.Client.UpdateStatusAsync(new DiscordActivity }); }; - this._musicProvider.PlayEndEvent += async (_, _) => + this._musicProvider.PlayEndEvent += async (sender, _) => { - if (this._musicProvider.RemainingInQueue == 0) + if (sender.RemainingInQueue == 0) { await this.Client.UpdateStatusAsync(new DiscordActivity { diff --git a/Codehard.DJ/Providers/IMusicProvider.cs b/Codehard.DJ/Providers/IMusicProvider.cs index 5d68234..87c69bb 100644 --- a/Codehard.DJ/Providers/IMusicProvider.cs +++ b/Codehard.DJ/Providers/IMusicProvider.cs @@ -1,4 +1,5 @@ using Codehard.DJ.Providers.Models; +using Codehard.DJ.Providers.Spotify; namespace Codehard.DJ.Providers; @@ -21,6 +22,8 @@ public interface IMusicProvider : IDisposable int RemainingInQueue { get; } + PlaybackState State { get; } + ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default); ValueTask> GetCurrentQueueAsync(CancellationToken cancellationToken = default); diff --git a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 01c47d5..027577d 100644 --- a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -38,6 +38,8 @@ public SpotifyProvider( public int RemainingInQueue => this._queue.Count; + public PlaybackState State { get; private set; } + public async ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default) { var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); @@ -132,6 +134,8 @@ private async void GetPlayingTrackInfoAsync(object? state) { var playbackState = await IsCurrentPlaybackEndedAsync(); + this.State = playbackState; + switch (playbackState) { case PlaybackState.Playing: @@ -140,13 +144,13 @@ private async void GetPlayingTrackInfoAsync(object? state) case PlaybackState.Stopped: if (this._currentMusic != null) { + this._playedStack.Push(this._currentMusic); + this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs { Music = this._currentMusic!, }); - this._playedStack.Push(this._currentMusic); - this._currentMusic = null; } @@ -159,16 +163,6 @@ private async void GetPlayingTrackInfoAsync(object? state) default: throw new NotSupportedException(); } - - if (this._currentMusic != null) - { - this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs - { - Music = this._currentMusic!, - }); - - this._playedStack.Push(this._currentMusic); - } } catch (APIException ex) { From 3220ce59e4d52f860430a5c08e265f9156f27afe Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 11:22:03 +0700 Subject: [PATCH 14/25] Update cache policy for skip --- Codehard.DJ/DjDiscordClient.cs | 40 +++++++++++++++++++++----------- Infrastructure.Discord/Emojis.cs | 1 + 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index faa2d64..f3693ce 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -69,25 +69,37 @@ public DjCommandHandler( { this._musicProvider = musicProvider; this._logger = logger; + + this._musicProvider.PlayEndEvent += (sender, args) => + { + var key = args.Music.RandomIdentifier.ToString(); + + if (this._cache.Contains(key)) + this._cache.Remove(key); + }; } [Command("skip")] - public Task SkipMusicAsync(CommandContext ctx) + public async Task SkipMusicAsync(CommandContext ctx) { - return PerformWithThrottlePolicy(ctx, m => $"skip-{m.Id}", async (context, member) => + if (!TryGetMember(ctx, out var member)) { - if (!IsCurrentSongOwner(member)) - { - await ReactAsync(context, Emojis.ThumbsDown); - await context.RespondAsync("Please respect others rights!"); + await ReactAsync(ctx, Emojis.ThumbsDown); - return; - } + return; + } - await this._musicProvider.NextAsync(); + if (!IsCurrentSongOwner(member)) + { + await ReactAsync(ctx, Emojis.ThumbsDown); + await ctx.RespondAsync("Please respect others rights!"); - await ReactAsync(context, Emojis.ThumbsUp); - }); + return; + } + + await this._musicProvider.NextAsync(); + + await ReactAsync(ctx, Emojis.ThumbsUp); } [Command("q")] @@ -104,7 +116,7 @@ public Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText this._cache.Add( music.RandomIdentifier.ToString(), member.Id, - DateTimeOffset.UtcNow.AddMinutes(5)); + DateTimeOffset.UtcNow.AddHours(3)); await this._musicProvider.EnqueueAsync(music); @@ -225,10 +237,10 @@ private async Task PerformWithThrottlePolicy( if (this.TryGetCache(key, out DateTimeOffset expirationDateTimeOffset)) { - await ReactAsync(context, Emojis.ThumbsDown); + await ReactAsync(context, Emojis.NoEntry); await context.RespondAsync( - $"You're being throttle, " + + $"You're being throttled, " + $"please try again in {(int)expirationDateTimeOffset.Subtract(DateTimeOffset.UtcNow).TotalSeconds} second(s)."); return; diff --git a/Infrastructure.Discord/Emojis.cs b/Infrastructure.Discord/Emojis.cs index f736026..2f7475c 100644 --- a/Infrastructure.Discord/Emojis.cs +++ b/Infrastructure.Discord/Emojis.cs @@ -4,4 +4,5 @@ public static class Emojis { public const string ThumbsUp = ":thumbsup:"; public const string ThumbsDown = ":thumbsdown:"; + public const string NoEntry = ":no_entry_sign:"; } \ No newline at end of file From 8f73eacd41c087a3d506a2514920ce10a3d23f02 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 16:59:43 +0700 Subject: [PATCH 15/25] Add member and played track --- Codehard.DJ.sln | 12 ++ Codehard.DJ/Bootstrap.cs | 27 +++- Codehard.DJ/Codehard.DJ.csproj | 2 + Codehard.DJ/DjDiscordClient.cs | 148 +++++++++++++----- Codehard.DJ/Program.cs | 8 + Codehard.DJ/Providers/Models/Artist.cs | 6 + Codehard.DJ/Providers/Models/Music.cs | 7 +- .../Providers/Spotify/SpotifyProvider.cs | 9 +- DJ.Domain/DJ.Domain.csproj | 9 ++ DJ.Domain/Entities/Member.cs | 54 +++++++ DJ.Domain/Entities/Partials/Member.cs | 6 + DJ.Domain/Entities/PlayedTrack.cs | 68 ++++++++ DJ.Domain/Entities/PlayedTrackContainer.cs | 15 ++ DJ.Domain/Interfaces/IMemberRepository.cs | 8 + DJ.Domain/Interfaces/IRepository.cs | 34 ++++ DJ.Domain/MemberRepository.cs | 5 + DJ.Infrastructure/DJ.Infrastructure.csproj | 24 +++ DJ.Infrastructure/DjDbContext.cs | 63 ++++++++ DJ.Infrastructure/DjDesignTimeDbContext.cs | 18 +++ ...0129095713_InitializeDbContext.Designer.cs | 97 ++++++++++++ .../20230129095713_InitializeDbContext.cs | 71 +++++++++ .../Migrations/DjDbContextModelSnapshot.cs | 94 +++++++++++ .../Repositories/GenericRepository.cs | 98 ++++++++++++ .../Repositories/MemberRepository.cs | 30 ++++ .../ServiceCollectionExtensions.cs | 20 +++ Infrastructure.Domain/Class1.cs | 5 + .../Infrastructure.Domain.csproj | 9 ++ 27 files changed, 900 insertions(+), 47 deletions(-) create mode 100644 Codehard.DJ/Providers/Models/Artist.cs create mode 100644 DJ.Domain/DJ.Domain.csproj create mode 100644 DJ.Domain/Entities/Member.cs create mode 100644 DJ.Domain/Entities/Partials/Member.cs create mode 100644 DJ.Domain/Entities/PlayedTrack.cs create mode 100644 DJ.Domain/Entities/PlayedTrackContainer.cs create mode 100644 DJ.Domain/Interfaces/IMemberRepository.cs create mode 100644 DJ.Domain/Interfaces/IRepository.cs create mode 100644 DJ.Domain/MemberRepository.cs create mode 100644 DJ.Infrastructure/DJ.Infrastructure.csproj create mode 100644 DJ.Infrastructure/DjDbContext.cs create mode 100644 DJ.Infrastructure/DjDesignTimeDbContext.cs create mode 100644 DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.Designer.cs create mode 100644 DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.cs create mode 100644 DJ.Infrastructure/Migrations/DjDbContextModelSnapshot.cs create mode 100644 DJ.Infrastructure/Repositories/GenericRepository.cs create mode 100644 DJ.Infrastructure/Repositories/MemberRepository.cs create mode 100644 DJ.Infrastructure/ServiceCollectionExtensions.cs create mode 100644 Infrastructure.Domain/Class1.cs create mode 100644 Infrastructure.Domain/Infrastructure.Domain.csproj diff --git a/Codehard.DJ.sln b/Codehard.DJ.sln index 1873473..9329f98 100644 --- a/Codehard.DJ.sln +++ b/Codehard.DJ.sln @@ -4,6 +4,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codehard.DJ", "Codehard.DJ\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Discord", "Infrastructure.Discord\Infrastructure.Discord.csproj", "{1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DJ.Domain", "DJ.Domain\DJ.Domain.csproj", "{42301BDB-63DB-4F18-A542-3EDB3CDB8FCB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DJ.Infrastructure", "DJ.Infrastructure\DJ.Infrastructure.csproj", "{0A1D4F4D-DEFA-450D-8AB5-7D37C52D86A2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +22,13 @@ Global {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Debug|Any CPU.Build.0 = Debug|Any CPU {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Release|Any CPU.ActiveCfg = Release|Any CPU {1EDF35FF-A84C-4C62-9365-6B6AF8BD0607}.Release|Any CPU.Build.0 = Release|Any CPU + {42301BDB-63DB-4F18-A542-3EDB3CDB8FCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42301BDB-63DB-4F18-A542-3EDB3CDB8FCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42301BDB-63DB-4F18-A542-3EDB3CDB8FCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42301BDB-63DB-4F18-A542-3EDB3CDB8FCB}.Release|Any CPU.Build.0 = Release|Any CPU + {0A1D4F4D-DEFA-450D-8AB5-7D37C52D86A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A1D4F4D-DEFA-450D-8AB5-7D37C52D86A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A1D4F4D-DEFA-450D-8AB5-7D37C52D86A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A1D4F4D-DEFA-450D-8AB5-7D37C52D86A2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Codehard.DJ/Bootstrap.cs b/Codehard.DJ/Bootstrap.cs index 1a150a6..053c080 100644 --- a/Codehard.DJ/Bootstrap.cs +++ b/Codehard.DJ/Bootstrap.cs @@ -1,4 +1,7 @@ -using SpotifyAPI.Web; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SpotifyAPI.Web; using SpotifyAPI.Web.Auth; namespace Codehard.DJ; @@ -81,4 +84,26 @@ public static async Task InitializeSpotifyClientAsync( } } } + + public static async Task ApplyMigrationsAsync(this IHost host, CancellationToken cancellationToken = default) + where T : DbContext + { + using var scope = host.Services.CreateScope(); + + await using var dbContext = scope.ServiceProvider.GetService(); + + if (dbContext == null) + { + throw new InvalidOperationException($"Unable to resolve '{typeof(T)}' from host."); + } + + var migrations = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken); + + if (migrations.Any()) + { + await dbContext.Database.MigrateAsync(CancellationToken.None); + } + + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + } } \ No newline at end of file diff --git a/Codehard.DJ/Codehard.DJ.csproj b/Codehard.DJ/Codehard.DJ.csproj index 082ad72..a4ac7f1 100644 --- a/Codehard.DJ/Codehard.DJ.csproj +++ b/Codehard.DJ/Codehard.DJ.csproj @@ -22,6 +22,8 @@ + + diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index f3693ce..b4869b1 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -1,9 +1,13 @@ using System.Runtime.Caching; using System.Text; using Codehard.DJ.Providers; +using DJ.Domain.Entities; +using DJ.Domain.Interfaces; +using DSharpPlus; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; +using DSharpPlus.EventArgs; using Infrastructure.Discord; using Microsoft.Extensions.Logging; @@ -11,12 +15,14 @@ namespace Codehard.DJ; public class DjDiscordClient : DiscordClientAbstract { + private readonly IMemberRepository _memberRepository; private readonly ILogger _logger; private readonly IMusicProvider _musicProvider; public DjDiscordClient( string token, IServiceProvider serviceProvider, + IMemberRepository memberRepository, ILogger logger, IMusicProvider musicProvider) : base( @@ -27,12 +33,13 @@ public DjDiscordClient( true, serviceProvider) { + _memberRepository = memberRepository; this._logger = logger; this._musicProvider = musicProvider; this._musicProvider.PlayStartEvent += async (sender, args) => { - var name = $"{args.Music.Title} - {args.Music.Album} by {string.Join(", ", args.Music.Artists)}"; + var name = $"{args.Music.Title} - {args.Music.Album} by {string.Join(", ", args.Music.Artists.Select(a => a.Name))}"; await this.Client.UpdateStatusAsync(new DiscordActivity { @@ -52,6 +59,36 @@ await this.Client.UpdateStatusAsync(new DiscordActivity }); } }; + + this.Client.GuildDownloadCompleted += GuildDownloadedHandler; + this.Client.GuildMemberAdded += GuildMemberAdded; + } + + private async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) + { + var guildMember = e.Member; + + var exist = await this._memberRepository.AnyAsync(m => m.Id == guildMember.Id); + + if (!exist) + { + var member = Member.Create(guildMember.Id, guildMember.Guild.Id); + + await this._memberRepository.AddAsync(member); + await this._memberRepository.SaveChangesAsync(); + } + } + + private async Task GuildDownloadedHandler(DiscordClient sender, GuildDownloadCompletedEventArgs e) + { + var guilds = e.Guilds.Select(g => g.Value); + + var members = + guilds.SelectMany(g => + g.Members.Values.Select(m => Member.Create(m.Id, g.Id))); + + await this._memberRepository.AddNewMembersAsync(members); + await this._memberRepository.SaveChangesAsync(); } } @@ -60,13 +97,16 @@ public class DjCommandHandler : BaseCommandModule private const string CacheName = "CommandCache"; private readonly MemoryCache _cache = new(CacheName); + private readonly IMemberRepository _memberRepository; private readonly IMusicProvider _musicProvider; private readonly ILogger _logger; public DjCommandHandler( + IMemberRepository memberRepository, IMusicProvider musicProvider, ILogger logger) { + _memberRepository = memberRepository; this._musicProvider = musicProvider; this._logger = logger; @@ -82,7 +122,9 @@ public DjCommandHandler( [Command("skip")] public async Task SkipMusicAsync(CommandContext ctx) { - if (!TryGetMember(ctx, out var member)) + var member = await GetMemberAsync(ctx); + + if (member == null) { await ReactAsync(ctx, Emojis.ThumbsDown); @@ -109,55 +151,66 @@ public Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText { var musics = (await this._musicProvider.SearchAsync(queryText)).ToArray(); - if (musics.Any()) + if (!musics.Any()) { - var music = musics.First(); + await ReactAsync(context, Emojis.ThumbsDown); - this._cache.Add( - music.RandomIdentifier.ToString(), - member.Id, - DateTimeOffset.UtcNow.AddHours(3)); + return; + } - await this._musicProvider.EnqueueAsync(music); + var music = musics.First(); - await ReactAsync(context, Emojis.ThumbsUp); + this._cache.Add( + music.RandomIdentifier.ToString(), + member.Id, + DateTimeOffset.UtcNow.AddHours(3)); - var queue = await this._musicProvider.GetCurrentQueueAsync(); + await this._musicProvider.EnqueueAsync(music); - var prevs = queue.TakeLast(3).ToArray(); + await ReactAsync(context, Emojis.ThumbsUp); - if (!prevs.Any()) - { - return; - } + member.AddTrack( + music.Id, + music.Title, + music.Artists.Select(a => a.Name), + music.Album, + music.PlaySourceUri, + music.Artists.SelectMany(a => a.Genres)); - var totalInQueue = this._musicProvider.RemainingInQueue; + await this._memberRepository.UpdateAsync(member); + await this._memberRepository.SaveChangesAsync(); - if (totalInQueue > 1) - { - await context.RespondAsync($"{totalInQueue - 1} music(s) ahead in queue"); - } + var queue = await this._musicProvider.GetCurrentQueueAsync(); - var sb = new StringBuilder(); + var prevs = queue.TakeLast(3).ToArray(); - foreach (var m in prevs) - { - sb.AppendLine($"- {m.Title} {m.Album} {string.Join(", ", m.Artists)}"); - } + if (!prevs.Any()) + { + return; + } - var embed = new DiscordEmbedBuilder - { - Title = $"The last {prevs.Length} music(s) in queue are", - Description = sb.ToString(), - Color = new Optional(DiscordColor.Blue), - }; + var totalInQueue = this._musicProvider.RemainingInQueue; - await ctx.RespondAsync(embed); + if (totalInQueue > 1) + { + await context.RespondAsync($"{totalInQueue - 1} music(s) ahead in queue"); } - else + + var sb = new StringBuilder(); + + foreach (var m in prevs) { - await ReactAsync(context, Emojis.ThumbsDown); + sb.AppendLine($"- {m.Title} {m.Album} {string.Join(", ", m.Artists.Select(a => a.Name))}"); } + + var embed = new DiscordEmbedBuilder + { + Title = $"The last {prevs.Length} music(s) in queue are", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Blue), + }; + + await ctx.RespondAsync(embed); }); } @@ -208,7 +261,7 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe foreach (var music in searchResult) { - sb.AppendLine($"{music.Title} {music.Album} {string.Join(", ", music.Artists)}"); + sb.AppendLine($"{music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}"); } var embed = new DiscordEmbedBuilder @@ -223,10 +276,12 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe private async Task PerformWithThrottlePolicy( CommandContext context, - Func keyFunc, - Func func) + Func keyFunc, + Func func) { - if (!TryGetMember(context, out var member)) + var member = await GetMemberAsync(context); + + if (member == null) { await ReactAsync(context, Emojis.ThumbsDown); @@ -256,7 +311,7 @@ await context.RespondAsync( }); } - private bool IsCurrentSongOwner(DiscordMember member) + private bool IsCurrentSongOwner(Member member) { var current = this._musicProvider.Current; @@ -287,8 +342,17 @@ private static Task ReactAsync(CommandContext ctx, string emojiName) return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); } - private static bool TryGetMember(CommandContext context, out DiscordMember member) + private async Task GetMemberAsync(CommandContext context) { - return (member = context.Member!) != null; + var discordMember = context.Member; + + if (discordMember == null) + { + return null; + } + + var member = await this._memberRepository.GetByIdAsync(discordMember.Id); + + return member; } } \ No newline at end of file diff --git a/Codehard.DJ/Program.cs b/Codehard.DJ/Program.cs index 1cdd518..f560869 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -3,6 +3,8 @@ using Codehard.DJ; using Codehard.DJ.Providers; using Codehard.DJ.Providers.Spotify; +using DJ.Domain.Interfaces; +using DJ.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -46,15 +48,21 @@ new DjDiscordClient( discordConfig["Token"]!, sp, + sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService())); services.AddHostedService(); } + + services.AddDjDbContext(configuration.GetConnectionString("DjDatabase")!); + services.AddRepositories(); }); builder.UseConsoleLifetime(); var app = builder.Build(); +await app.ApplyMigrationsAsync(); + await app.RunAsync(); \ No newline at end of file diff --git a/Codehard.DJ/Providers/Models/Artist.cs b/Codehard.DJ/Providers/Models/Artist.cs new file mode 100644 index 0000000..c055101 --- /dev/null +++ b/Codehard.DJ/Providers/Models/Artist.cs @@ -0,0 +1,6 @@ +namespace Codehard.DJ.Providers.Models; + +public sealed record Artist( + string Id, + string Name, + IReadOnlyCollection Genres); \ No newline at end of file diff --git a/Codehard.DJ/Providers/Models/Music.cs b/Codehard.DJ/Providers/Models/Music.cs index ea8c601..6a46554 100644 --- a/Codehard.DJ/Providers/Models/Music.cs +++ b/Codehard.DJ/Providers/Models/Music.cs @@ -5,8 +5,9 @@ public sealed record Music public Music( string id, string title, - string[] artists, + Artist[] artists, string album, + string genre, Uri? playSourceUri) { this.Id = id; @@ -21,7 +22,7 @@ public Music( public string Title { get; } - public string[] Artists { get; } + public Artist[] Artists { get; } public string Album { get; } @@ -31,6 +32,6 @@ public Music( public override string ToString() { - return $"🎵 {this.Title} - {this.Album} by {string.Join(", ", this.Artists)}"; + return $"🎵 {this.Title} - {this.Album} by {string.Join(", ", this.Artists.Select(a => a.Name))}"; } } \ No newline at end of file diff --git a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 027577d..6c0f627 100644 --- a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -48,8 +48,15 @@ public async ValueTask> SearchAsync(string query, Cancellatio .Select(item => new Music( item.Id, item.Name, - item.Artists.Select(a => a.Name).ToArray(), + item.Artists.Join( + searchResponse.Artists.Items!, + l => l.Id, + r => r.Id, + (_, r) => r) + .Select(fa => new Artist(fa.Id, fa.Name, fa.Genres)) + .ToArray(), item.Album.Name, + string.Empty, new Uri(item.Uri))) ?? Enumerable.Empty(); } diff --git a/DJ.Domain/DJ.Domain.csproj b/DJ.Domain/DJ.Domain.csproj new file mode 100644 index 0000000..6836c68 --- /dev/null +++ b/DJ.Domain/DJ.Domain.csproj @@ -0,0 +1,9 @@ + + + + net7.0 + enable + enable + + + diff --git a/DJ.Domain/Entities/Member.cs b/DJ.Domain/Entities/Member.cs new file mode 100644 index 0000000..38d7183 --- /dev/null +++ b/DJ.Domain/Entities/Member.cs @@ -0,0 +1,54 @@ +namespace DJ.Domain.Entities; + +public partial class Member +{ + private readonly List _playedTracks = new(); + + public Member() + { + // Entity Framework Constructor + } + + private Member( + ulong id, + ulong guildId, + DateTimeOffset createdAt) + { + this.Id = id; + this.GuildId = guildId; + this.CreatedAt = createdAt; + } + + public ulong Id { get; } + + public ulong GuildId { get; } + + public DateTimeOffset CreatedAt { get; } + + public IReadOnlyCollection PlayedTracks => this._playedTracks; + + public void AddTrack( + string trackId, + string title, + IEnumerable artists, + string album, + Uri? uri, + IEnumerable genres) + { + var playedTrack = + PlayedTrack.Create( + trackId, + title, + artists.Distinct().ToArray(), + album, + genres.Distinct().ToArray(), + uri); + + this._playedTracks.Add(playedTrack); + } + + public static Member Create( + ulong id, + ulong guildId) + => new(id, guildId, DateTimeOffset.UtcNow); +} \ No newline at end of file diff --git a/DJ.Domain/Entities/Partials/Member.cs b/DJ.Domain/Entities/Partials/Member.cs new file mode 100644 index 0000000..0036a62 --- /dev/null +++ b/DJ.Domain/Entities/Partials/Member.cs @@ -0,0 +1,6 @@ +namespace DJ.Domain.Entities; + +public partial class Member +{ + public static readonly string PlayedTracksProperty = nameof(_playedTracks); +} \ No newline at end of file diff --git a/DJ.Domain/Entities/PlayedTrack.cs b/DJ.Domain/Entities/PlayedTrack.cs new file mode 100644 index 0000000..db3c842 --- /dev/null +++ b/DJ.Domain/Entities/PlayedTrack.cs @@ -0,0 +1,68 @@ +namespace DJ.Domain.Entities; + +public class PlayedTrack +{ + public PlayedTrack() + { + // Entity Framework Constructor + } + + private PlayedTrack( + Guid id, + string trackId, + string title, + string[] artists, + string album, + string[] genres, + int score, + Uri? uri, + DateTimeOffset createdAt) + { + this.Id = id; + this.TrackId = trackId; + this.Title = title; + this.Artists = artists; + this.Album = album; + this.Genres = genres; + this.Score = score; + this.Uri = uri; + this.CreatedAt = createdAt; + } + + public Guid Id { get; } + + public string TrackId { get; } + + public string Title { get; } + + public string[] Artists { get; } + + public string Album { get; } + + public string[] Genres { get; } + + public int Score { get; } + + public Uri? Uri { get; } + + public DateTimeOffset CreatedAt { get; } + + public virtual Member Member { get; protected set; } + + internal static PlayedTrack Create( + string trackId, + string title, + string[] artists, + string album, + string[] genres, + Uri? uri) + => new(Guid.Empty, + trackId, + title, + artists, + album, + genres, + 5, + uri, + DateTimeOffset.UtcNow); +} \ No newline at end of file diff --git a/DJ.Domain/Entities/PlayedTrackContainer.cs b/DJ.Domain/Entities/PlayedTrackContainer.cs new file mode 100644 index 0000000..0cce3a6 --- /dev/null +++ b/DJ.Domain/Entities/PlayedTrackContainer.cs @@ -0,0 +1,15 @@ +namespace DJ.Domain.Entities; + +public class PlayedTrackContainer +{ + public const string PlayedTracksBackingField = nameof(_playedTracks); + + private readonly List _playedTracks = new(); + + public IReadOnlyCollection PlayedTracks => this._playedTracks; + + public void AddTrack(PlayedTrack playedTrack) + { + this._playedTracks.Add(playedTrack); + } +} \ No newline at end of file diff --git a/DJ.Domain/Interfaces/IMemberRepository.cs b/DJ.Domain/Interfaces/IMemberRepository.cs new file mode 100644 index 0000000..03ccca1 --- /dev/null +++ b/DJ.Domain/Interfaces/IMemberRepository.cs @@ -0,0 +1,8 @@ +using DJ.Domain.Entities; + +namespace DJ.Domain.Interfaces; + +public interface IMemberRepository : IRepository +{ + Task AddNewMembersAsync(IEnumerable members, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/DJ.Domain/Interfaces/IRepository.cs b/DJ.Domain/Interfaces/IRepository.cs new file mode 100644 index 0000000..cc3343d --- /dev/null +++ b/DJ.Domain/Interfaces/IRepository.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; + +namespace DJ.Domain.Interfaces; + +public interface IRepository +{ + ValueTask GetByIdAsync(object id, CancellationToken cancellationToken = default); + + ValueTask GetByIdAsync(object[] id, CancellationToken cancellationToken = default); + + T Add(T entity); + + Task AddAsync(T entity, CancellationToken cancellationToken = default); + + Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + void Remove(T entity); + + Task RemoveAsync(T entity, CancellationToken cancellationToken = default); + + void Update(T entity); + + Task UpdateAsync(T entity, CancellationToken cancellationToken = default); + + Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default); + + Task CountAsync(CancellationToken cancellationToken = default); + + Task CountAsync(Expression> predicate, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/DJ.Domain/MemberRepository.cs b/DJ.Domain/MemberRepository.cs new file mode 100644 index 0000000..2f0bb3c --- /dev/null +++ b/DJ.Domain/MemberRepository.cs @@ -0,0 +1,5 @@ +namespace DJ.Domain; + +public class MemberRepository +{ +} \ No newline at end of file diff --git a/DJ.Infrastructure/DJ.Infrastructure.csproj b/DJ.Infrastructure/DJ.Infrastructure.csproj new file mode 100644 index 0000000..27ab48f --- /dev/null +++ b/DJ.Infrastructure/DJ.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + diff --git a/DJ.Infrastructure/DjDbContext.cs b/DJ.Infrastructure/DjDbContext.cs new file mode 100644 index 0000000..e40cc90 --- /dev/null +++ b/DJ.Infrastructure/DjDbContext.cs @@ -0,0 +1,63 @@ +using DJ.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DJ.Infrastructure; + +public class DjDbContext : DbContext +{ + public DjDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Members { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureMember(modelBuilder); + } + + private static void ConfigureMember(ModelBuilder modelBuilder) + { + modelBuilder.Entity(builder => + { + builder.HasKey(m => m.Id); + builder.Property(m => m.GuildId) + .IsRequired(); + builder.Property(m => m.CreatedAt) + .IsRequired(); + + builder.OwnsMany(m => m.PlayedTracks, ConfigurePlayedTrack); + }); + } + + private static void ConfigurePlayedTrack(OwnedNavigationBuilder builder) + { + builder.ToTable("PlayedTracks"); + builder.HasKey(t => t.Id); + builder.Property(t => t.TrackId) + .IsRequired(); + builder.Property(t => t.Title) + .IsRequired(); + builder.Property(t => t.Artists) + .HasConversion( + mem => string.Join(',', mem), + db => db.Split(',', StringSplitOptions.RemoveEmptyEntries)) + .IsRequired(); + builder.Property(t => t.Album) + .IsRequired(); + builder.Property(t => t.Genres) + .HasConversion( + mem => string.Join(',', mem), + db => db.Split(',', StringSplitOptions.RemoveEmptyEntries)) + .IsRequired(); + builder.Property(t => t.Score) + .IsRequired(); + builder.Property(t => t.Uri); + builder.Property(t => t.CreatedAt) + .IsRequired(); + + builder.Metadata.PrincipalToDependent!.SetField(Member.PlayedTracksProperty); + } +} \ No newline at end of file diff --git a/DJ.Infrastructure/DjDesignTimeDbContext.cs b/DJ.Infrastructure/DjDesignTimeDbContext.cs new file mode 100644 index 0000000..8ff8de5 --- /dev/null +++ b/DJ.Infrastructure/DjDesignTimeDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace DJ.Infrastructure; + +public class DjDesignTimeDbContext : IDesignTimeDbContextFactory +{ + public DjDbContext CreateDbContext(string[] args) + { + var connectionString = "Data Source=dj.db;"; + + var optionsBuilder = new DbContextOptionsBuilder(); + + optionsBuilder.UseSqlite(connectionString); + + return new DjDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.Designer.cs b/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.Designer.cs new file mode 100644 index 0000000..2cb6ffc --- /dev/null +++ b/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.Designer.cs @@ -0,0 +1,97 @@ +// +using System; +using DJ.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DJ.Infrastructure.Migrations +{ + [DbContext(typeof(DjDbContext))] + [Migration("20230129095713_InitializeDbContext")] + partial class InitializeDbContext + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.2"); + + modelBuilder.Entity("DJ.Domain.Entities.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("DJ.Domain.Entities.Member", b => + { + b.OwnsMany("DJ.Domain.Entities.PlayedTrack", "PlayedTracks", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b1.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Artists") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("CreatedAt") + .HasColumnType("TEXT"); + + b1.Property("Genres") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("MemberId") + .HasColumnType("INTEGER"); + + b1.Property("Score") + .HasColumnType("INTEGER"); + + b1.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("TrackId") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Uri") + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("MemberId"); + + b1.ToTable("PlayedTracks", (string)null); + + b1.WithOwner("Member") + .HasForeignKey("MemberId"); + + b1.Navigation("Member"); + }); + + b.Navigation("PlayedTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.cs b/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.cs new file mode 100644 index 0000000..ec17ed5 --- /dev/null +++ b/DJ.Infrastructure/Migrations/20230129095713_InitializeDbContext.cs @@ -0,0 +1,71 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DJ.Infrastructure.Migrations +{ + /// + public partial class InitializeDbContext : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Members", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GuildId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Members", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayedTracks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TrackId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Artists = table.Column(type: "TEXT", nullable: false), + Album = table.Column(type: "TEXT", nullable: false), + Genres = table.Column(type: "TEXT", nullable: false), + Score = table.Column(type: "INTEGER", nullable: false), + Uri = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + MemberId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayedTracks", x => x.Id); + table.ForeignKey( + name: "FK_PlayedTracks_Members_MemberId", + column: x => x.MemberId, + principalTable: "Members", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayedTracks_MemberId", + table: "PlayedTracks", + column: "MemberId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayedTracks"); + + migrationBuilder.DropTable( + name: "Members"); + } + } +} diff --git a/DJ.Infrastructure/Migrations/DjDbContextModelSnapshot.cs b/DJ.Infrastructure/Migrations/DjDbContextModelSnapshot.cs new file mode 100644 index 0000000..ffe7c89 --- /dev/null +++ b/DJ.Infrastructure/Migrations/DjDbContextModelSnapshot.cs @@ -0,0 +1,94 @@ +// +using System; +using DJ.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DJ.Infrastructure.Migrations +{ + [DbContext(typeof(DjDbContext))] + partial class DjDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.2"); + + modelBuilder.Entity("DJ.Domain.Entities.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("DJ.Domain.Entities.Member", b => + { + b.OwnsMany("DJ.Domain.Entities.PlayedTrack", "PlayedTracks", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b1.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Artists") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("CreatedAt") + .HasColumnType("TEXT"); + + b1.Property("Genres") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("MemberId") + .HasColumnType("INTEGER"); + + b1.Property("Score") + .HasColumnType("INTEGER"); + + b1.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("TrackId") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Uri") + .HasColumnType("TEXT"); + + b1.HasKey("Id"); + + b1.HasIndex("MemberId"); + + b1.ToTable("PlayedTracks", (string)null); + + b1.WithOwner("Member") + .HasForeignKey("MemberId"); + + b1.Navigation("Member"); + }); + + b.Navigation("PlayedTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DJ.Infrastructure/Repositories/GenericRepository.cs b/DJ.Infrastructure/Repositories/GenericRepository.cs new file mode 100644 index 0000000..caa4737 --- /dev/null +++ b/DJ.Infrastructure/Repositories/GenericRepository.cs @@ -0,0 +1,98 @@ +using System.Linq.Expressions; +using DJ.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace DJ.Infrastructure.Repositories; + +internal abstract class GenericRepository : IRepository + where T : class +{ + private readonly DbContext _dbContext; + + protected GenericRepository(DbContext dbContext) + { + _dbContext = dbContext; + } + + protected abstract DbSet Set { get; } + + public ValueTask GetByIdAsync(object id, CancellationToken cancellationToken = default) + { + return this.GetByIdAsync(new[] { id }, cancellationToken); + } + + public ValueTask GetByIdAsync(object[] id, CancellationToken cancellationToken = default) + { + return this.Set.FindAsync(id, cancellationToken); + } + + public T Add(T entity) + { + var entityEntry = this.Set.Add(entity); + + return entityEntry.Entity; + } + + public async Task AddAsync(T entity, CancellationToken cancellationToken = default) + { + var entityEntry = await this.Set.AddAsync(entity, cancellationToken); + + return entityEntry.Entity; + } + + public Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + return this.Set.AddRangeAsync(entities, cancellationToken); + } + + public void Remove(T entity) + { + this.Set.Remove(entity); + } + + public Task RemoveAsync(T entity, CancellationToken cancellationToken = default) + { + this.Set.Remove(entity); + + return Task.CompletedTask; + } + + public void Update(T entity) + { + this.Set.Update(entity); + } + + public Task UpdateAsync(T entity, CancellationToken cancellationToken = default) + { + this.Set.Update(entity); + + return Task.CompletedTask; + } + + public Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + this.Set.UpdateRange(entities); + + return Task.CompletedTask; + } + + public Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return this.Set.AnyAsync(predicate, cancellationToken); + } + + public Task CountAsync(CancellationToken cancellationToken = default) + { + return this.Set.CountAsync(cancellationToken); + } + + public Task CountAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return this.Set.CountAsync(predicate, cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return this._dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/DJ.Infrastructure/Repositories/MemberRepository.cs b/DJ.Infrastructure/Repositories/MemberRepository.cs new file mode 100644 index 0000000..bfda660 --- /dev/null +++ b/DJ.Infrastructure/Repositories/MemberRepository.cs @@ -0,0 +1,30 @@ +using DJ.Domain.Entities; +using DJ.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace DJ.Infrastructure.Repositories; + +internal sealed class MemberRepository : GenericRepository, IMemberRepository +{ + private readonly DjDbContext _dbContext; + + public MemberRepository(DjDbContext dbContext) + : base(dbContext) + { + this._dbContext = dbContext; + } + + protected override DbSet Set => this._dbContext.Members; + + public Task AddNewMembersAsync(IEnumerable members, CancellationToken cancellationToken = default) + { + var existingMembers = this.Set.Select(m => m.Id).AsEnumerable(); + + var newMembers = members.ExceptBy(existingMembers, m => m.Id).ToArray(); + + return + !newMembers.Any() + ? Task.CompletedTask + : this.AddRangeAsync(newMembers, cancellationToken); + } +} \ No newline at end of file diff --git a/DJ.Infrastructure/ServiceCollectionExtensions.cs b/DJ.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9147ff8 --- /dev/null +++ b/DJ.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using DJ.Domain.Interfaces; +using DJ.Infrastructure; +using DJ.Infrastructure.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddDjDbContext(this IServiceCollection services, string connectionString) + { + services.AddDbContext(options => options.UseSqlite(connectionString)); + } + + public static void AddRepositories(this IServiceCollection services) + { + services.TryAddScoped(); + } +} \ No newline at end of file diff --git a/Infrastructure.Domain/Class1.cs b/Infrastructure.Domain/Class1.cs new file mode 100644 index 0000000..2f565a6 --- /dev/null +++ b/Infrastructure.Domain/Class1.cs @@ -0,0 +1,5 @@ +namespace Infrastructure.Domain; + +public class Class1 +{ +} \ No newline at end of file diff --git a/Infrastructure.Domain/Infrastructure.Domain.csproj b/Infrastructure.Domain/Infrastructure.Domain.csproj new file mode 100644 index 0000000..6836c68 --- /dev/null +++ b/Infrastructure.Domain/Infrastructure.Domain.csproj @@ -0,0 +1,9 @@ + + + + net7.0 + enable + enable + + + From 4a4d89ca592c7af14ce684322c20e4b06808d877 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 17:10:29 +0700 Subject: [PATCH 16/25] Add artist info to search api --- Codehard.DJ/DjDiscordClient.cs | 2 +- Codehard.DJ/Providers/Spotify/SpotifyProvider.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index b4869b1..fcaec6f 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -301,7 +301,7 @@ await context.RespondAsync( return; } - var expireTime = DateTimeOffset.UtcNow.AddMinutes(3); + var expireTime = DateTimeOffset.UtcNow.AddSeconds(3); await func(context, member); diff --git a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 6c0f627..eb16e9c 100644 --- a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -44,12 +44,16 @@ public async ValueTask> SearchAsync(string query, Cancellatio { var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); + var artistIds = searchResponse.Tracks.Items!.SelectMany(t => t.Artists.Select(a => a.Id)); + + var artistsResponse = await this._client.Artists.GetSeveral(new ArtistsRequest(artistIds.ToList()), cancellationToken); + return searchResponse.Tracks.Items? .Select(item => new Music( item.Id, item.Name, item.Artists.Join( - searchResponse.Artists.Items!, + artistsResponse.Artists, l => l.Id, r => r.Id, (_, r) => r) From 693df45c3ea19940f2418c7901825628d7f7815f Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 17:15:33 +0700 Subject: [PATCH 17/25] Remove unused project --- DJ.Domain/MemberRepository.cs | 5 ----- DJ.Infrastructure/ServiceCollectionExtensions.cs | 4 +++- Infrastructure.Domain/Class1.cs | 5 ----- Infrastructure.Domain/Infrastructure.Domain.csproj | 9 --------- 4 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 DJ.Domain/MemberRepository.cs delete mode 100644 Infrastructure.Domain/Class1.cs delete mode 100644 Infrastructure.Domain/Infrastructure.Domain.csproj diff --git a/DJ.Domain/MemberRepository.cs b/DJ.Domain/MemberRepository.cs deleted file mode 100644 index 2f0bb3c..0000000 --- a/DJ.Domain/MemberRepository.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace DJ.Domain; - -public class MemberRepository -{ -} \ No newline at end of file diff --git a/DJ.Infrastructure/ServiceCollectionExtensions.cs b/DJ.Infrastructure/ServiceCollectionExtensions.cs index 9147ff8..d7b0b4d 100644 --- a/DJ.Infrastructure/ServiceCollectionExtensions.cs +++ b/DJ.Infrastructure/ServiceCollectionExtensions.cs @@ -10,7 +10,9 @@ public static class ServiceCollectionExtensions { public static void AddDjDbContext(this IServiceCollection services, string connectionString) { - services.AddDbContext(options => options.UseSqlite(connectionString)); + services.AddDbContext(options => + options.UseSqlite(connectionString) + .UseLazyLoadingProxies()); } public static void AddRepositories(this IServiceCollection services) diff --git a/Infrastructure.Domain/Class1.cs b/Infrastructure.Domain/Class1.cs deleted file mode 100644 index 2f565a6..0000000 --- a/Infrastructure.Domain/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Infrastructure.Domain; - -public class Class1 -{ -} \ No newline at end of file diff --git a/Infrastructure.Domain/Infrastructure.Domain.csproj b/Infrastructure.Domain/Infrastructure.Domain.csproj deleted file mode 100644 index 6836c68..0000000 --- a/Infrastructure.Domain/Infrastructure.Domain.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net7.0 - enable - enable - - - From 8214363c0e0d618b7a2ec149a75b8da9dc811a49 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 20:34:43 +0700 Subject: [PATCH 18/25] Allow cooldown to be adjust via configuration --- Codehard.DJ/DjDiscordClient.cs | 11 +++++-- .../Providers/Spotify/SpotifyProvider.cs | 33 ++++++++++--------- Codehard.DJ/appsettings.json | 6 +++- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index fcaec6f..8799241 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -9,6 +9,7 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using Infrastructure.Discord; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Codehard.DJ; @@ -100,15 +101,21 @@ public class DjCommandHandler : BaseCommandModule private readonly IMemberRepository _memberRepository; private readonly IMusicProvider _musicProvider; private readonly ILogger _logger; + private readonly int _cooldown; public DjCommandHandler( IMemberRepository memberRepository, IMusicProvider musicProvider, + IConfiguration configuration, ILogger logger) { - _memberRepository = memberRepository; + this._memberRepository = memberRepository; this._musicProvider = musicProvider; this._logger = logger; + this._cooldown = + configuration.GetSection("Configurations") + .GetSection("Discord") + .GetValue("CommandCooldown"); this._musicProvider.PlayEndEvent += (sender, args) => { @@ -301,7 +308,7 @@ await context.RespondAsync( return; } - var expireTime = DateTimeOffset.UtcNow.AddSeconds(3); + var expireTime = DateTimeOffset.UtcNow.AddSeconds(this._cooldown); await func(context, member); diff --git a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index eb16e9c..8d882a4 100644 --- a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -48,21 +48,24 @@ public async ValueTask> SearchAsync(string query, Cancellatio var artistsResponse = await this._client.Artists.GetSeveral(new ArtistsRequest(artistIds.ToList()), cancellationToken); - return searchResponse.Tracks.Items? - .Select(item => new Music( - item.Id, - item.Name, - item.Artists.Join( - artistsResponse.Artists, - l => l.Id, - r => r.Id, - (_, r) => r) - .Select(fa => new Artist(fa.Id, fa.Name, fa.Genres)) - .ToArray(), - item.Album.Name, - string.Empty, - new Uri(item.Uri))) - ?? Enumerable.Empty(); + var res = searchResponse.Tracks.Items? + .Select(item => new Music( + item.Id, + item.Name, + item.Artists + .Join( + artistsResponse.Artists.DistinctBy(a => a.Id), + l => l.Id, + r => r.Id, + (_, r) => r) + .Select(fa => new Artist(fa.Id, fa.Name, fa.Genres)) + .ToArray(), + item.Album.Name, + string.Empty, + new Uri(item.Uri))) + ?? Enumerable.Empty(); + + return res; } public ValueTask> GetCurrentQueueAsync(CancellationToken cancellationToken = default) diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index 39eb1c8..717db0d 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -1,11 +1,15 @@ { + "ConnectionStrings": { + "DjDatabase": "Data Source=dj.db;" + }, "Configurations": { "Spotify": { "ClientId": "dbd6f23048e64779862538ff17bfa402", "ClientSecret": "7df3f17b8240433c9cddc8f92cf6cf6e" }, "Discord": { - "Active": false, + "Active": true, + "CommandCooldown": 30, "Token": "" } }, From 2b7ef17ed16066ee780b6caddda70682ccdc1159 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Sun, 29 Jan 2023 22:09:38 +0700 Subject: [PATCH 19/25] Add stat command --- Codehard.DJ/DjDiscordClient.cs | 55 ++++++++++++++++++++++ DJ.Domain/Entities/PlayedTrackContainer.cs | 15 ------ 2 files changed, 55 insertions(+), 15 deletions(-) delete mode 100644 DJ.Domain/Entities/PlayedTrackContainer.cs diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 8799241..5b22d50 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -138,6 +138,14 @@ public async Task SkipMusicAsync(CommandContext ctx) return; } + if (this._musicProvider.Current == null) + { + await ReactAsync(ctx, Emojis.NoEntry); + await ctx.RespondAsync("Current track is not tracking by the bot."); + + return; + } + if (!IsCurrentSongOwner(member)) { await ReactAsync(ctx, Emojis.ThumbsDown); @@ -281,6 +289,53 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe await ctx.RespondAsync(embed); } + [Command("stat")] + public async Task GetStatAsync(CommandContext ctx) + { + var member = await GetMemberAsync(ctx); + + if (member == null) + { + await ReactAsync(ctx, Emojis.ThumbsDown); + + return; + } + + var playedTracks = member.PlayedTracks; + + if (!playedTracks.Any()) + { + await ctx.RespondAsync("There is no history so far, try queueing one!"); + + return; + } + + var mostPlayedGenre = + playedTracks.SelectMany(t => t.Genres).GroupBy(g => g).MaxBy(g => g.Count())?.Key; + var mostPlayedArtist = + playedTracks.SelectMany(t => t.Artists).GroupBy(a => a).MaxBy(g => g.Count())?.Key; + var totalPlayedTracks = playedTracks.Count; + var averagePlayedPerDay = + playedTracks.GroupBy(t => new { t.CreatedAt.Day, t.CreatedAt.Month, t.CreatedAt.Year }) + .MaxBy(d => d.Count())? + .Count() ?? 0; + + var sb = new StringBuilder(); + sb.AppendLine($"🎵 Most played genre: {mostPlayedGenre ?? "Unknown"}"); + sb.AppendLine($"🎵 Most played artist: {mostPlayedArtist ?? "Unknown"}"); + sb.AppendLine($"🎵 Total requested tracks: {totalPlayedTracks}"); + sb.AppendLine($"🎵 Average requested track per day: {averagePlayedPerDay}"); + + var embed = new DiscordEmbedBuilder + { + Title = "Here's some insight for you", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Purple), + }; + + await ctx.RespondAsync(embed); + } + private async Task PerformWithThrottlePolicy( CommandContext context, Func keyFunc, diff --git a/DJ.Domain/Entities/PlayedTrackContainer.cs b/DJ.Domain/Entities/PlayedTrackContainer.cs deleted file mode 100644 index 0cce3a6..0000000 --- a/DJ.Domain/Entities/PlayedTrackContainer.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DJ.Domain.Entities; - -public class PlayedTrackContainer -{ - public const string PlayedTracksBackingField = nameof(_playedTracks); - - private readonly List _playedTracks = new(); - - public IReadOnlyCollection PlayedTracks => this._playedTracks; - - public void AddTrack(PlayedTrack playedTrack) - { - this._playedTracks.Add(playedTrack); - } -} \ No newline at end of file From 8420e956f9954494345ba78c96da6a450e7f74b2 Mon Sep 17 00:00:00 2001 From: "Wasupol.S" Date: Mon, 30 Jan 2023 13:37:29 +0700 Subject: [PATCH 20/25] Add bullet icon --- Codehard.DJ/DjDiscordClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 5b22d50..0d5bf9b 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -276,7 +276,7 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe foreach (var music in searchResult) { - sb.AppendLine($"{music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}"); + sb.AppendLine($"➡️ {music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}"); } var embed = new DiscordEmbedBuilder From 54128f6123faf1018b830b9048aeb0ff082eec70 Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Mon, 30 Jan 2023 23:25:03 +0700 Subject: [PATCH 21/25] Allow reaction to queue the song instantly --- Codehard.DJ/DjDiscordClient.cs | 247 ++++++++++-------- Codehard.DJ/Partials/DjDiscordClient.cs | 136 ++++++++++ Codehard.DJ/Providers/IMusicProvider.cs | 2 +- .../Providers/Spotify/SpotifyProvider.cs | 4 +- 4 files changed, 284 insertions(+), 105 deletions(-) create mode 100644 Codehard.DJ/Partials/DjDiscordClient.cs diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 0d5bf9b..bcc7060 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -1,5 +1,6 @@ using System.Runtime.Caching; using System.Text; +using System.Threading.Channels; using Codehard.DJ.Providers; using DJ.Domain.Entities; using DJ.Domain.Interfaces; @@ -14,6 +15,23 @@ namespace Codehard.DJ; +file sealed record Message(DiscordClient Client, DiscordChannel Channel, DiscordUser User, string Content); + +file static class SharedChannel +{ + public static readonly ChannelReader Reader; + + public static readonly ChannelWriter Writer; + + static SharedChannel() + { + var channel = Channel.CreateBounded(100); + + Reader = channel.Reader; + Writer = channel.Writer; + } +} + public class DjDiscordClient : DiscordClientAbstract { private readonly IMemberRepository _memberRepository; @@ -30,11 +48,11 @@ public DjDiscordClient( token, new[] { "!" }, new[] { typeof(DjCommandHandler) }, - false, + true, true, serviceProvider) { - _memberRepository = memberRepository; + this._memberRepository = memberRepository; this._logger = logger; this._musicProvider = musicProvider; @@ -63,6 +81,26 @@ await this.Client.UpdateStatusAsync(new DiscordActivity this.Client.GuildDownloadCompleted += GuildDownloadedHandler; this.Client.GuildMemberAdded += GuildMemberAdded; + this.Client.MessageReactionAdded += MessageReactionAddedHandler; + } + + private async Task MessageReactionAddedHandler(DiscordClient sender, MessageReactionAddEventArgs e) + { + if (e.Message.Author.Id != sender.CurrentUser.Id) + { + return; + } + + var message = await e.Channel.GetMessageAsync(e.Message.Id); + + if (!message.Embeds.Any()) + { + return; + } + + var embedContent = message.Embeds[0].Description; + + await SharedChannel.Writer.WriteAsync(new Message(sender, e.Channel, e.User, embedContent)); } private async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) @@ -93,7 +131,7 @@ private async Task GuildDownloadedHandler(DiscordClient sender, GuildDownloadCom } } -public class DjCommandHandler : BaseCommandModule +public partial class DjCommandHandler : BaseCommandModule { private const string CacheName = "CommandCache"; @@ -101,6 +139,7 @@ public class DjCommandHandler : BaseCommandModule private readonly IMemberRepository _memberRepository; private readonly IMusicProvider _musicProvider; private readonly ILogger _logger; + private readonly Timer channelReaderTimer; private readonly int _cooldown; public DjCommandHandler( @@ -124,6 +163,19 @@ public DjCommandHandler( if (this._cache.Contains(key)) this._cache.Remove(key); }; + + Task.Run(ReadChannelAsync); + } + + private async Task ReadChannelAsync() + { + while (await SharedChannel.Reader.WaitToReadAsync()) + { + while (SharedChannel.Reader.TryRead(out var message)) + { + await QueueMusicAsync(message.Client, message.Channel, message.User, message.Content); + } + } } [Command("skip")] @@ -164,7 +216,7 @@ public Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText { return PerformWithThrottlePolicy(ctx, m => $"queue-{m.Id}", async (context, member) => { - var musics = (await this._musicProvider.SearchAsync(queryText)).ToArray(); + var musics = (await this._musicProvider.SearchAsync(queryText, 1)).ToArray(); if (!musics.Any()) { @@ -229,6 +281,82 @@ public Task QueueMusicAsync(CommandContext ctx, [RemainingText] string queryText }); } + public Task QueueMusicAsync( + DiscordClient client, + DiscordChannel channel, + DiscordUser discordUser, + string queryText) + { + return PerformWithThrottlePolicy( + client, + channel, + discordUser, + m => $"queue-{m.Id}", async (_, _, _, member) => + { + var musics = (await this._musicProvider.SearchAsync(queryText)).ToArray(); + + if (!musics.Any()) + { + await ReactAsync(client, channel, discordUser, Emojis.ThumbsDown); + + return; + } + + var music = musics.First(); + + this._cache.Add( + music.RandomIdentifier.ToString(), + member.Id, + DateTimeOffset.UtcNow.AddHours(3)); + + await this._musicProvider.EnqueueAsync(music); + await ReactAsync(client, channel, discordUser, Emojis.ThumbsUp); + + member.AddTrack( + music.Id, + music.Title, + music.Artists.Select(a => a.Name), + music.Album, + music.PlaySourceUri, + music.Artists.SelectMany(a => a.Genres)); + + await this._memberRepository.UpdateAsync(member); + await this._memberRepository.SaveChangesAsync(); + + var queue = await this._musicProvider.GetCurrentQueueAsync(); + + var prevs = queue.TakeLast(3).ToArray(); + + if (!prevs.Any()) + { + return; + } + + var totalInQueue = this._musicProvider.RemainingInQueue; + + if (totalInQueue > 1) + { + await client.SendMessageAsync(channel, $"{discordUser.Mention} {totalInQueue - 1} music(s) ahead in queue"); + } + + var sb = new StringBuilder(); + + foreach (var m in prevs) + { + sb.AppendLine($"- {m.Title} {m.Album} {string.Join(", ", m.Artists.Select(a => a.Name))}"); + } + + var embed = new DiscordEmbedBuilder + { + Title = $"The last {prevs.Length} music(s) in queue are", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Blue), + }; + + await client.SendMessageAsync(channel, embed); + }); + } + [Command("list-q")] public async Task ListQueueAsync(CommandContext ctx) { @@ -263,7 +391,7 @@ public async Task ListQueueAsync(CommandContext ctx) [Command("search")] public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryText) { - var searchResult = (await this._musicProvider.SearchAsync(queryText)).ToArray(); + var searchResult = (await this._musicProvider.SearchAsync(queryText, 3)).ToArray(); if (!searchResult.Any()) { @@ -272,21 +400,17 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe return; } - var sb = new StringBuilder(); - foreach (var music in searchResult) { - sb.AppendLine($"➡️ {music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}"); - } - - var embed = new DiscordEmbedBuilder - { - Title = $"Search result for {queryText}", - Description = sb.ToString(), - Color = new Optional(DiscordColor.Blue), - }; + var embed = new DiscordEmbedBuilder + { + Title = $"{queryText} from {ctx.User.Username}", + Description = $"{music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}", + Color = new Optional(DiscordColor.Blue), + }; - await ctx.RespondAsync(embed); + await ctx.RespondAsync(embed); + } } [Command("stat")] @@ -315,10 +439,11 @@ public async Task GetStatAsync(CommandContext ctx) var mostPlayedArtist = playedTracks.SelectMany(t => t.Artists).GroupBy(a => a).MaxBy(g => g.Count())?.Key; var totalPlayedTracks = playedTracks.Count; - var averagePlayedPerDay = + var totalPlayedDays = playedTracks.GroupBy(t => new { t.CreatedAt.Day, t.CreatedAt.Month, t.CreatedAt.Year }) - .MaxBy(d => d.Count())? - .Count() ?? 0; + .Select(g => g.Key) + .Count(); + var averagePlayedPerDay = playedTracks.Count / totalPlayedDays; var sb = new StringBuilder(); sb.AppendLine($"🎵 Most played genre: {mostPlayedGenre ?? "Unknown"}"); @@ -335,86 +460,4 @@ public async Task GetStatAsync(CommandContext ctx) await ctx.RespondAsync(embed); } - - private async Task PerformWithThrottlePolicy( - CommandContext context, - Func keyFunc, - Func func) - { - var member = await GetMemberAsync(context); - - if (member == null) - { - await ReactAsync(context, Emojis.ThumbsDown); - - return; - } - - var key = keyFunc(member); - - if (this.TryGetCache(key, out DateTimeOffset expirationDateTimeOffset)) - { - await ReactAsync(context, Emojis.NoEntry); - - await context.RespondAsync( - $"You're being throttled, " + - $"please try again in {(int)expirationDateTimeOffset.Subtract(DateTimeOffset.UtcNow).TotalSeconds} second(s)."); - - return; - } - - var expireTime = DateTimeOffset.UtcNow.AddSeconds(this._cooldown); - - await func(context, member); - - this._cache.Add(key, expireTime, new CacheItemPolicy - { - AbsoluteExpiration = expireTime, - }); - } - - private bool IsCurrentSongOwner(Member member) - { - var current = this._musicProvider.Current; - - if (current == null || !TryGetCache(current.RandomIdentifier.ToString(), out ulong memberId)) - { - return false; - } - - return member.Id == memberId; - } - - private bool TryGetCache(string key, out T value) - { - if (!this._cache.Contains(key)) - { - value = default!; - - return false; - } - - value = (T)this._cache[key]; - - return true; - } - - private static Task ReactAsync(CommandContext ctx, string emojiName) - { - return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); - } - - private async Task GetMemberAsync(CommandContext context) - { - var discordMember = context.Member; - - if (discordMember == null) - { - return null; - } - - var member = await this._memberRepository.GetByIdAsync(discordMember.Id); - - return member; - } } \ No newline at end of file diff --git a/Codehard.DJ/Partials/DjDiscordClient.cs b/Codehard.DJ/Partials/DjDiscordClient.cs new file mode 100644 index 0000000..4462d51 --- /dev/null +++ b/Codehard.DJ/Partials/DjDiscordClient.cs @@ -0,0 +1,136 @@ +using System.Runtime.Caching; +using DJ.Domain.Entities; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using Infrastructure.Discord; + +namespace Codehard.DJ; + +public partial class DjCommandHandler +{ + private async Task PerformWithThrottlePolicy( + DiscordClient client, + DiscordChannel channel, + DiscordUser discordUser, + Func keyFunc, + Func func) + { + var member = await GetMemberAsync(discordUser); + + if (member == null) + { + await client.SendMessageAsync(channel, $"{discordUser.Mention} {Emojis.ThumbsDown}"); + + return; + } + + var key = keyFunc(member); + + if (this.TryGetCache(key, out DateTimeOffset expirationDateTimeOffset)) + { + await client.SendMessageAsync( + channel, + $"{Emojis.NoEntry} {discordUser.Mention} You're being throttled, " + + $"please try again in {(int)expirationDateTimeOffset.Subtract(DateTimeOffset.UtcNow).TotalSeconds} second(s)."); + + return; + } + + var expireTime = DateTimeOffset.UtcNow.AddSeconds(this._cooldown); + + await func(client, channel, discordUser, member); + + this._cache.Add(key, expireTime, new CacheItemPolicy + { + AbsoluteExpiration = expireTime, + }); + } + + private async Task PerformWithThrottlePolicy( + CommandContext context, + Func keyFunc, + Func func) + { + var member = await GetMemberAsync(context); + + if (member == null) + { + await ReactAsync(context, Emojis.ThumbsDown); + + return; + } + + var key = keyFunc(member); + + if (this.TryGetCache(key, out DateTimeOffset expirationDateTimeOffset)) + { + await ReactAsync(context, Emojis.NoEntry); + + await context.RespondAsync( + $"You're being throttled, " + + $"please try again in {(int)expirationDateTimeOffset.Subtract(DateTimeOffset.UtcNow).TotalSeconds} second(s)."); + + return; + } + + var expireTime = DateTimeOffset.UtcNow.AddSeconds(this._cooldown); + + await func(context, member); + + this._cache.Add(key, expireTime, new CacheItemPolicy + { + AbsoluteExpiration = expireTime, + }); + } + + private bool IsCurrentSongOwner(Member member) + { + var current = this._musicProvider.Current; + + if (current == null || !TryGetCache(current.RandomIdentifier.ToString(), out ulong memberId)) + { + return false; + } + + return member.Id == memberId; + } + + private bool TryGetCache(string key, out T value) + { + if (!this._cache.Contains(key)) + { + value = default!; + + return false; + } + + value = (T)this._cache[key]; + + return true; + } + + private static Task ReactAsync(CommandContext ctx, string emojiName) + { + return ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, emojiName)); + } + + private static Task ReactAsync(DiscordClient client, DiscordChannel channel, DiscordUser user, string emojiName) + { + return client.SendMessageAsync(channel, $"{user.Mention} {emojiName}"); + } + + private Task GetMemberAsync(CommandContext context) + { + var discordMember = context.Member ?? context.User; + + return GetMemberAsync(discordMember); + } + + private async Task GetMemberAsync(DiscordUser discordUser) + { + var member = await this._memberRepository.GetByIdAsync(discordUser.Id); + + return member; + } +} \ No newline at end of file diff --git a/Codehard.DJ/Providers/IMusicProvider.cs b/Codehard.DJ/Providers/IMusicProvider.cs index 87c69bb..bbe788c 100644 --- a/Codehard.DJ/Providers/IMusicProvider.cs +++ b/Codehard.DJ/Providers/IMusicProvider.cs @@ -24,7 +24,7 @@ public interface IMusicProvider : IDisposable PlaybackState State { get; } - ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default); + ValueTask> SearchAsync(string query, int limit = 10, CancellationToken cancellationToken = default); ValueTask> GetCurrentQueueAsync(CancellationToken cancellationToken = default); diff --git a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 8d882a4..9357946 100644 --- a/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs +++ b/Codehard.DJ/Providers/Spotify/SpotifyProvider.cs @@ -40,9 +40,9 @@ public SpotifyProvider( public PlaybackState State { get; private set; } - public async ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default) + public async ValueTask> SearchAsync(string query, int limit = 10, CancellationToken cancellationToken = default) { - var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); + var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query) { Limit = limit, }, cancellationToken); var artistIds = searchResponse.Tracks.Items!.SelectMany(t => t.Artists.Select(a => a.Id)); From 00c18fbb25df1a607bd780101dde9b6bb3b9668d Mon Sep 17 00:00:00 2001 From: Desz01ate Date: Tue, 31 Jan 2023 09:40:32 +0700 Subject: [PATCH 22/25] Enhance search result cosmetic --- Codehard.DJ/DjDiscordClient.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index bcc7060..2416365 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -98,9 +98,14 @@ private async Task MessageReactionAddedHandler(DiscordClient sender, MessageReac return; } - var embedContent = message.Embeds[0].Description; + var query = message.Embeds[0].Footer?.Text; - await SharedChannel.Writer.WriteAsync(new Message(sender, e.Channel, e.User, embedContent)); + if (string.IsNullOrWhiteSpace(query)) + { + return; + } + + await SharedChannel.Writer.WriteAsync(new Message(sender, e.Channel, e.User, query)); } private async Task GuildMemberAdded(DiscordClient sender, GuildMemberAddEventArgs e) @@ -402,10 +407,16 @@ public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryTe foreach (var music in searchResult) { + var artists = string.Join(", ", music.Artists.Select(a => a.Name)); + var embed = new DiscordEmbedBuilder { Title = $"{queryText} from {ctx.User.Username}", - Description = $"{music.Title} {music.Album} {string.Join(", ", music.Artists.Select(a => a.Name))}", + Description = $"🎵 {music.Title}\n🧑‍🎤 {artists}\n💿 {music.Album}", + Footer = new DiscordEmbedBuilder.EmbedFooter() + { + Text = $"{music.Title} {music.Album} {artists}", + }, Color = new Optional(DiscordColor.Blue), }; From f81891a6d533d4174a9620397bf90775081af005 Mon Sep 17 00:00:00 2001 From: "Wasupol.S" Date: Sun, 5 Feb 2023 19:44:44 +0700 Subject: [PATCH 23/25] Add search page size --- Codehard.DJ/DjDiscordClient.cs | 38 ++++++++++++++++++++++++++++++++++ Codehard.DJ/appsettings.json | 10 ++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Codehard.DJ/DjDiscordClient.cs b/Codehard.DJ/DjDiscordClient.cs index 2416365..b03de05 100644 --- a/Codehard.DJ/DjDiscordClient.cs +++ b/Codehard.DJ/DjDiscordClient.cs @@ -393,6 +393,44 @@ public async Task ListQueueAsync(CommandContext ctx) await ctx.RespondAsync(embed); } + [Command("search")] + public async Task SearchAsync(CommandContext ctx, int pageSize, [RemainingText] string queryText) + { + if ((pageSize <= 0) || (pageSize > 10)) + { + await ctx.RespondAsync("Page size is between 1 to 10."); + + return; + } + + var searchResult = (await this._musicProvider.SearchAsync(queryText, pageSize)).ToArray(); + + if (!searchResult.Any()) + { + await ctx.RespondAsync("There is music matching your search text."); + + return; + } + + foreach (var music in searchResult) + { + var artists = string.Join(", ", music.Artists.Select(a => a.Name)); + + var embed = new DiscordEmbedBuilder + { + Title = $"{queryText} from {ctx.User.Username}", + Description = $"🎵 {music.Title}\n🧑‍🎤 {artists}\n💿 {music.Album}", + Footer = new DiscordEmbedBuilder.EmbedFooter() + { + Text = $"{music.Title} {music.Album} {artists}", + }, + Color = new Optional(DiscordColor.Blue), + }; + + await ctx.RespondAsync(embed); + } + } + [Command("search")] public async Task SearchAsync(CommandContext ctx, [RemainingText] string queryText) { diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index 717db0d..f735c11 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -7,11 +7,11 @@ "ClientId": "dbd6f23048e64779862538ff17bfa402", "ClientSecret": "7df3f17b8240433c9cddc8f92cf6cf6e" }, - "Discord": { - "Active": true, - "CommandCooldown": 30, - "Token": "" - } + "Discord": { + "Active": true, + "CommandCooldown": 30, + "Token": "MTA3MTc2MDU4NDg2NzA1NzY3NA.GcL-1j.s-PeCX7bcucs6bEZUpBcyXRUzX8oLo1UqS0tnA" + } }, "Logging": { "LogLevel": { From 96b88d0e155f34e2746866b0a03a6c0f563088bf Mon Sep 17 00:00:00 2001 From: "Wasupol.S" Date: Sun, 5 Feb 2023 19:45:54 +0700 Subject: [PATCH 24/25] Remove token in appsettings --- Codehard.DJ/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index f735c11..f6898e2 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -10,7 +10,7 @@ "Discord": { "Active": true, "CommandCooldown": 30, - "Token": "MTA3MTc2MDU4NDg2NzA1NzY3NA.GcL-1j.s-PeCX7bcucs6bEZUpBcyXRUzX8oLo1UqS0tnA" + "Token": "" } }, "Logging": { From 61dcdeddd7553a5a8ce07f02a303ac97c61c0c25 Mon Sep 17 00:00:00 2001 From: "Wasupol.S" Date: Sun, 5 Feb 2023 19:46:47 +0700 Subject: [PATCH 25/25] Re formet appsettings --- Codehard.DJ/appsettings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Codehard.DJ/appsettings.json b/Codehard.DJ/appsettings.json index f6898e2..717db0d 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -7,11 +7,11 @@ "ClientId": "dbd6f23048e64779862538ff17bfa402", "ClientSecret": "7df3f17b8240433c9cddc8f92cf6cf6e" }, - "Discord": { - "Active": true, - "CommandCooldown": 30, - "Token": "" - } + "Discord": { + "Active": true, + "CommandCooldown": 30, + "Token": "" + } }, "Logging": { "LogLevel": {