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 diff --git a/Codehard.DJ.sln b/Codehard.DJ.sln index cecb170..9329f98 100644 --- a/Codehard.DJ.sln +++ b/Codehard.DJ.sln @@ -2,6 +2,12 @@ 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 +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 @@ -12,5 +18,17 @@ 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 + {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 288e6a7..053c080 100644 --- a/Codehard.DJ/Bootstrap.cs +++ b/Codehard.DJ/Bootstrap.cs @@ -1,26 +1,44 @@ -using SpotifyAPI.Web; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SpotifyAPI.Web; using SpotifyAPI.Web.Auth; 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(config); }; + server.ErrorReceived += async (sender, error, state) => { Console.WriteLine($"Aborting authorization, error received: {error}"); @@ -33,7 +51,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 +69,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); @@ -66,4 +84,26 @@ public static async Task InitializeSpotifyClientAsync(string clie } } } + + 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 480ccf4..a4ac7f1 100644 --- a/Codehard.DJ/Codehard.DJ.csproj +++ b/Codehard.DJ/Codehard.DJ.csproj @@ -12,6 +12,7 @@ + @@ -20,4 +21,10 @@ + + + + + + diff --git a/Codehard.DJ/DiscordBotHostingService.cs b/Codehard.DJ/DiscordBotHostingService.cs new file mode 100644 index 0000000..f422258 --- /dev/null +++ b/Codehard.DJ/DiscordBotHostingService.cs @@ -0,0 +1,38 @@ +using Codehard.DJ.Providers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Codehard.DJ; + +public class DiscordBotHostingService : IHostedService +{ + private readonly DjDiscordClient _discordClient; + private readonly IMusicProvider _provider; + private readonly ILogger _logger; + + public DiscordBotHostingService( + DjDiscordClient discordClient, + IMusicProvider provider, + ILogger logger) + { + this._discordClient = discordClient; + this._logger = logger; + this._provider = provider; + 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) + { + 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..b03de05 --- /dev/null +++ b/Codehard.DJ/DjDiscordClient.cs @@ -0,0 +1,512 @@ +using System.Runtime.Caching; +using System.Text; +using System.Threading.Channels; +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.Configuration; +using Microsoft.Extensions.Logging; + +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; + private readonly ILogger _logger; + private readonly IMusicProvider _musicProvider; + + public DjDiscordClient( + string token, + IServiceProvider serviceProvider, + IMemberRepository memberRepository, + ILogger logger, + IMusicProvider musicProvider) + : base( + token, + new[] { "!" }, + new[] { typeof(DjCommandHandler) }, + true, + true, + serviceProvider) + { + this._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.Select(a => a.Name))}"; + + await this.Client.UpdateStatusAsync(new DiscordActivity + { + Name = name.Length > 128 ? name[..128] : name, + ActivityType = ActivityType.ListeningTo, + }); + }; + + this._musicProvider.PlayEndEvent += async (sender, _) => + { + if (sender.RemainingInQueue == 0) + { + await this.Client.UpdateStatusAsync(new DiscordActivity + { + Name = "the empty queue", + ActivityType = ActivityType.Watching, + }); + } + }; + + 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 query = message.Embeds[0].Footer?.Text; + + 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) + { + 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(); + } +} + +public partial 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; + private readonly Timer channelReaderTimer; + private readonly int _cooldown; + + public DjCommandHandler( + IMemberRepository memberRepository, + IMusicProvider musicProvider, + IConfiguration configuration, + ILogger logger) + { + this._memberRepository = memberRepository; + this._musicProvider = musicProvider; + this._logger = logger; + this._cooldown = + configuration.GetSection("Configurations") + .GetSection("Discord") + .GetValue("CommandCooldown"); + + this._musicProvider.PlayEndEvent += (sender, args) => + { + var key = args.Music.RandomIdentifier.ToString(); + + 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")] + public async Task SkipMusicAsync(CommandContext ctx) + { + var member = await GetMemberAsync(ctx); + + if (member == null) + { + await ReactAsync(ctx, Emojis.ThumbsDown); + + 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); + await ctx.RespondAsync("Please respect others rights!"); + + return; + } + + await this._musicProvider.NextAsync(); + + await ReactAsync(ctx, Emojis.ThumbsUp); + } + + [Command("q")] + 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, 1)).ToArray(); + + if (!musics.Any()) + { + await ReactAsync(context, 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(context, 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 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.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); + }); + } + + 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) + { + await ReactAsync(ctx, Emojis.ThumbsUp); + + var queue = (await this._musicProvider.GetCurrentQueueAsync()).Take(10).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 top {queue.Length} music(s) in queue", + Description = sb.ToString(), + Color = new Optional(DiscordColor.Green), + }; + + 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) + { + var searchResult = (await this._musicProvider.SearchAsync(queryText, 3)).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("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 totalPlayedDays = + playedTracks.GroupBy(t => new { t.CreatedAt.Day, t.CreatedAt.Month, t.CreatedAt.Year }) + .Select(g => g.Key) + .Count(); + var averagePlayedPerDay = playedTracks.Count / totalPlayedDays; + + 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); + } +} \ 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/Program.cs b/Codehard.DJ/Program.cs index e8a8e1f..f560869 100644 --- a/Codehard.DJ/Program.cs +++ b/Codehard.DJ/Program.cs @@ -2,12 +2,14 @@ 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; using Microsoft.Extensions.Hosting; -using SpotifyAPI.Web; -using SpotifyAPI.Web.Auth; +using Microsoft.Extensions.Logging; var builder = Host.CreateDefaultBuilder(args); @@ -24,21 +26,43 @@ { 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"]; + var clientSecret = spotifyConfig["ClientSecret"]; - var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!).Result; + var spotifyClient = Bootstrap.InitializeSpotifyClientAsync(clientId!, clientSecret!).Result; 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>(), + 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/IMusicProvider.cs b/Codehard.DJ/Providers/IMusicProvider.cs index f835203..bbe788c 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; @@ -7,9 +8,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,7 +18,13 @@ public interface IMusicProvider : IDisposable event PlayEndEventHandler PlayEndEvent; - ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default); + Music? Current { get; } + + int RemainingInQueue { get; } + + PlaybackState State { get; } + + ValueTask> SearchAsync(string query, int limit = 10, CancellationToken cancellationToken = default); ValueTask> GetCurrentQueueAsync(CancellationToken cancellationToken = default); 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 e0358b5..6a46554 100644 --- a/Codehard.DJ/Providers/Models/Music.cs +++ b/Codehard.DJ/Providers/Models/Music.cs @@ -1,3 +1,37 @@ 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, + Artist[] artists, + string album, + string genre, + 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 Artist[] Artists { get; } + + public string Album { get; } + + public Uri? PlaySourceUri { get; } + + public Guid RandomIdentifier { get; } + + public override string ToString() + { + 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/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/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 53% rename from Codehard.DJ/Providers/SpotifyProvider.cs rename to Codehard.DJ/Providers/Spotify/SpotifyProvider.cs index 29f4c96..9357946 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 { @@ -27,20 +27,45 @@ 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; public event PlayEndEventHandler? PlayEndEvent; - public async ValueTask> SearchAsync(string query, CancellationToken cancellationToken = default) - { - var searchResponse = await this._client.Search.Item(new SearchRequest(SearchRequest.Types.All, query), cancellationToken); + public Music? Current => this._currentMusic; + + public int RemainingInQueue => this._queue.Count; + + public PlaybackState State { get; private set; } - return searchResponse.Tracks.Items? - .Select(item => new Music(item.Id, item.Name, new Uri(item.Uri))) - ?? Enumerable.Empty(); + 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) { Limit = limit, }, 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); + + 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) @@ -85,16 +110,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 @@ -129,24 +144,66 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) private async void GetPlayingTrackInfoAsync(object? state) { - var playingContext = await this._client.Player.GetCurrentPlayback(); + try + { + var playbackState = await IsCurrentPlaybackEndedAsync(); + + this.State = playbackState; - if (playingContext == null!) + switch (playbackState) + { + case PlaybackState.Playing: + return; + case PlaybackState.Ended: + case PlaybackState.Stopped: + if (this._currentMusic != null) + { + this._playedStack.Push(this._currentMusic); + + this.PlayEndEvent?.Invoke(this, new MusicPlayerEventArgs + { + Music = this._currentMusic!, + }); + + this._currentMusic = null; + } + + if (this._queue.Any()) + { + await this.NextAsync(); + } + + break; + default: + throw new NotSupportedException(); + } + } + catch (APIException ex) { - return; + this._logger.LogError(ex, "An error occurred during track info gathering"); } - if (playingContext.Item is not FullTrack currentTrack) + async Task IsCurrentPlaybackEndedAsync() { - return; - } + var playingContext = await this._client.Player.GetCurrentPlayback(); - var trackEnded = playingContext.ProgressMs >= currentTrack.DurationMs; - var trackStopped = !playingContext.IsPlaying && playingContext.ProgressMs == 0 && this._queue.Any(); + if (playingContext == null!) + { + return PlaybackState.Stopped; + } - if (trackEnded || trackStopped) - { - await this.NextAsync(); + 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; } } 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..717db0d 100644 --- a/Codehard.DJ/appsettings.json +++ b/Codehard.DJ/appsettings.json @@ -1,7 +1,16 @@ { + "ConnectionStrings": { + "DjDatabase": "Data Source=dj.db;" + }, "Configurations": { "Spotify": { - "ClientId": "dbd6f23048e64779862538ff17bfa402" + "ClientId": "dbd6f23048e64779862538ff17bfa402", + "ClientSecret": "7df3f17b8240433c9cddc8f92cf6cf6e" + }, + "Discord": { + "Active": true, + "CommandCooldown": 30, + "Token": "" } }, "Logging": { 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/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.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..d7b0b4d --- /dev/null +++ b/DJ.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +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) + .UseLazyLoadingProxies()); + } + + public static void AddRepositories(this IServiceCollection services) + { + services.TryAddScoped(); + } +} \ No newline at end of file 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..2f7475c --- /dev/null +++ b/Infrastructure.Discord/Emojis.cs @@ -0,0 +1,8 @@ +namespace Infrastructure.Discord; + +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 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 + + + + + + + +