diff --git a/Kasbot.APP/Kasbot.App.csproj b/Kasbot.APP/Kasbot.App.csproj index f6af1da..23f1a1c 100644 --- a/Kasbot.APP/Kasbot.App.csproj +++ b/Kasbot.APP/Kasbot.App.csproj @@ -18,12 +18,17 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + diff --git a/Kasbot.APP/Models/Connection.cs b/Kasbot.APP/Models/Connection.cs index 25858d1..440d1a4 100644 --- a/Kasbot.APP/Models/Connection.cs +++ b/Kasbot.APP/Models/Connection.cs @@ -1,6 +1,6 @@ using Discord; using Discord.Audio; -using Kasbot.Services.Internal; +using Kasbot.App.Services.Internal; namespace Kasbot.Models { diff --git a/Kasbot.APP/Program.cs b/Kasbot.APP/Program.cs index bbe60d1..ff8cf00 100644 --- a/Kasbot.APP/Program.cs +++ b/Kasbot.APP/Program.cs @@ -1,12 +1,14 @@ using Discord; using Discord.Commands; using Discord.WebSocket; -using Google.Protobuf.WellKnownTypes; using Kasbot.App.Internal.Services; +using Kasbot.App.Services.Internal; using Kasbot.Services; using Kasbot.Services.Internal; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Serilog; namespace Kasbot { @@ -102,15 +104,23 @@ namespace Kasbot private ServiceProvider ConfigureServices() { + var logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}") + .CreateBootstrapLogger(); + return new ServiceCollection() .AddSingleton(new DiscordSocketConfig { GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent, TotalShards = SHARDS }) + .AddSerilog(logger) .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/Kasbot.APP/Services/Internal/MediaService.cs b/Kasbot.APP/Services/Internal/MediaService.cs new file mode 100644 index 0000000..3f9ef0e --- /dev/null +++ b/Kasbot.APP/Services/Internal/MediaService.cs @@ -0,0 +1,112 @@ +using Discord.Rest; +using Discord.WebSocket; +using Kasbot.Models; +using Kasbot.Services.Internal; +using Serilog; +using YoutubeExplode.Videos; + +namespace Kasbot.App.Services.Internal +{ + public class MediaService + { + private YoutubeService YoutubeService { get; set; } + private SpotifyService SpotifyService { get; set; } + private ILogger Logger { get; set; } + + + public MediaService(YoutubeService youtubeService, SpotifyService spotifyService, ILogger logger) + { + this.YoutubeService = youtubeService; + this.SpotifyService = spotifyService; + this.Logger = logger; + } + + public async Task FetchSingleMedia(Media media, SearchType mediaType) + { + if (mediaType == SearchType.SpotifyTrack) + { + Logger.Debug($"Fetching single media: {media.Search}"); + media = await SpotifyService.FetchSingleMedia(media); + } + + Logger.Debug($"Fetching single media (YouTube): {media.Search}"); + media = await YoutubeService.FetchSingleMedia(media); + + if (media.VideoId == null) + { + Logger.Error($"No video found for \"{media.Search}\"."); + throw new Exception($"No video found for \"{media.Search}\"."); + } + + return media; + } + + public async Task FetchMediaCollection(Media rawMedia, SearchType mediaType) + { + var collection = new MediaCollection(); + + if (mediaType == SearchType.SpotifyPlaylist) + { + Logger.Debug($"Fetching playlist from Spotify: {rawMedia.Search}"); + collection = await SpotifyService.FetchPlaylist(rawMedia); + var tasks = collection.Medias.ToList().Select(media => YoutubeService.FetchSingleMedia(media)); + var results = await Task.WhenAll(tasks); + Logger.Debug($"Fetched playlist from Spotify: {rawMedia.Search}"); + } + + if (mediaType == SearchType.SpotifyAlbum) + { + Logger.Debug($"Fetching album from Spotify: {rawMedia.Search}"); + collection = await SpotifyService.FetchAlbum(rawMedia); + var tasks = collection.Medias.ToList().Select(media => YoutubeService.FetchSingleMedia(media)); + var results = await Task.WhenAll(tasks); + Logger.Debug($"Fetched album from Spotify: {rawMedia.Search}"); + } + + if (mediaType == SearchType.YoutubePlaylist) + { + Logger.Debug($"Fetching playlist from YouTube: {rawMedia.Search}"); + collection = await YoutubeService.FetchPlaylist(rawMedia); + Logger.Debug($"Fetched playlist from YouTube: {rawMedia.Search}"); + } + + return collection; + } + + public async Task DownloadAudioFromYoutube(Media media) + { + return await YoutubeService.DownloadAudioFromYoutube(media); + } + } + + public class Media + { + public string Search { get; set; } + + public string Name { get; set; } + public TimeSpan Length { get; set; } + public Flags Flags { get; set; } + + public VideoId? VideoId { get; set; } + public RestUserMessage PlayMessage { get; set; } + public RestUserMessage? QueueMessage { get; set; } + + private SocketUserMessage message; + public SocketUserMessage Message + { + get => message; + set + { + message = value; + Channel = value.Channel; + } + } + public ISocketMessageChannel Channel { get; private set; } + } + + public class MediaCollection + { + public string CollectionName { get; set; } + public List Medias { get; set; } = new List(); + } +} diff --git a/Kasbot.APP/Services/Internal/SpotifyService.cs b/Kasbot.APP/Services/Internal/SpotifyService.cs new file mode 100644 index 0000000..19a40c4 --- /dev/null +++ b/Kasbot.APP/Services/Internal/SpotifyService.cs @@ -0,0 +1,146 @@ +using Serilog; +using SpotifyAPI.Web; + +namespace Kasbot.App.Services.Internal +{ + public class SpotifyService + { + private readonly SpotifyClient? spotifyClient = null; + private ILogger Logger { get; set; } + + public SpotifyService(ILogger logger) + { + this.Logger = logger; + + this.spotifyClient = SetupSpotifyClient(); + } + + private SpotifyClient SetupSpotifyClient() + { + var spotifyClientId = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID"); + var spotifyClientSecret = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_SECRET"); + + if (spotifyClientId == null || spotifyClientSecret == null) + { + Logger.Warning("Spotify Token was not found. Will disable Spotify integration."); + return null; + } + + var config = SpotifyClientConfig.CreateDefault(); + + var request = new ClientCredentialsRequest(spotifyClientId, spotifyClientSecret); + var response = new OAuthClient(config).RequestToken(request).Result; + + return new SpotifyClient(config.WithToken(response.AccessToken)); + } + + public async Task FetchSingleMedia(Media media) + { + if (spotifyClient == null) + { + Logger.Warning("Spotify integration is disabled."); + throw new Exception("Spotify integration is disabled."); + } + + var trackId = UrlResolver.GetSpotifyResourceId(media.Search); + var spotifyTrack = await spotifyClient.Tracks.Get(trackId); + + if (spotifyTrack == null) + { + Logger.Error($"No track found on Spotify for \"{media.Search}\"."); + throw new Exception($"No track found on Spotify for \"{media.Search}\"."); + } + + media.Search = spotifyTrack.Name; + + return media; + } + + public async Task FetchPlaylist(Media rawMedia) + { + if (spotifyClient == null) + { + Logger.Warning("Spotify integration is disabled."); + throw new Exception("Spotify integration is disabled."); + } + + var playlistId = UrlResolver.GetSpotifyResourceId(rawMedia.Search); + var spotifyPlaylist = await spotifyClient.Playlists.Get(playlistId); + + if (spotifyPlaylist == null || spotifyPlaylist.Tracks == null) + { + Logger.Error($"No playlist found on Spotify for \"{rawMedia.Search}\"."); + throw new Exception($"No playlist found on Spotify for \"{rawMedia.Search}\"."); + } + + var collection = new MediaCollection(); + collection.CollectionName = spotifyPlaylist.Name ?? string.Empty; + + if (spotifyPlaylist.Tracks.Items == null || spotifyPlaylist.Tracks.Items.Count == 0) + { + Logger.Error($"No tracks found for playlist \"{spotifyPlaylist.Name}\"."); + throw new Exception($"No tracks found for playlist \"{spotifyPlaylist.Name}\"."); + } + + Logger.Debug($"Found {spotifyPlaylist.Tracks.Items.Count} tracks for playlist \"{spotifyPlaylist.Name}\"."); + + foreach (var playlistTrack in spotifyPlaylist.Tracks.Items) + { + if (playlistTrack.Track is not FullTrack track) + { + continue; + } + + collection.Medias.Add(new Media + { + Search = track.Name, + Message = rawMedia.Message, + Flags = rawMedia.Flags, + }); + } + + return collection; + } + + public async Task FetchAlbum(Media rawMedia) + { + if (spotifyClient == null) + { + Logger.Warning("Spotify integration is disabled."); + throw new Exception("Spotify integration is disabled."); + } + + var albumId = UrlResolver.GetSpotifyResourceId(rawMedia.Search); + var spotifyAlbum = await spotifyClient.Albums.Get(albumId); + + if (spotifyAlbum == null || spotifyAlbum.Tracks == null) + { + Logger.Error($"No album found on Spotify for \"{rawMedia.Search}\"."); + throw new Exception($"No album found on Spotify for \"{rawMedia.Search}\"."); + } + + var collection = new MediaCollection(); + collection.CollectionName = spotifyAlbum.Name ?? string.Empty; + + if (spotifyAlbum.Tracks.Items == null || spotifyAlbum.Tracks.Items.Count == 0) + { + Logger.Error($"No tracks found for album \"{spotifyAlbum.Name}\"."); + throw new Exception($"No tracks found for album \"{spotifyAlbum.Name}\"."); + } + + Logger.Debug($"Found {spotifyAlbum.Tracks.Items.Count} tracks for album \"{spotifyAlbum.Name}\"."); + + foreach (var track in spotifyAlbum.Tracks.Items) + { + collection.Medias.Add(new Media + { + Search = track.Name, + Message = rawMedia.Message, + Flags = rawMedia.Flags, + }); + } + + return collection; + } + } +} \ No newline at end of file diff --git a/Kasbot.APP/Services/Internal/UrlResolver.cs b/Kasbot.APP/Services/Internal/UrlResolver.cs new file mode 100644 index 0000000..e769e29 --- /dev/null +++ b/Kasbot.APP/Services/Internal/UrlResolver.cs @@ -0,0 +1,93 @@ +using System.Web; + +namespace Kasbot.App.Services.Internal +{ + public static class UrlResolver + { + private const string SpotifyUrl = "open.spotify.com"; + + public static SearchType GetSearchType(string query) + { + if (string.IsNullOrEmpty(query)) + return SearchType.None; + + if (IsURL(query)) + { + if (IsSpotifyUrl(query)) + { + if (query.Contains("/track/")) + return SearchType.SpotifyTrack; + + if (query.Contains("/album/")) + return SearchType.SpotifyAlbum; + + if (query.Contains("/playlist/")) + return SearchType.SpotifyPlaylist; + + if (query.Contains("/artist/")) + return SearchType.SpotifyArtist; + } + + if (query.Contains("playlist?list=")) + return SearchType.YoutubePlaylist; + + if (query.Contains("list=")) + return SearchType.VideoPlaylistURL; + + return SearchType.VideoURL; + } + + return SearchType.StringSearch; + } + + public static string GetVideoId(string url) + { + if (url.Contains("v=")) + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + return query["v"] ?? string.Empty; + } + + if (url.Contains("youtu.be/")) + { + var uri = new Uri(url); + return uri.Segments[1]; + } + + return url; + } + + public static string GetSpotifyResourceId(string url) + { + if (string.IsNullOrEmpty(url)) + return string.Empty; + + var uri = new Uri(url); + return uri.Segments[uri.Segments.Length - 1]; + } + + private static bool IsURL(string url) + { + return url.StartsWith("http://") || url.StartsWith("https://"); + } + + private static bool IsSpotifyUrl(string url) + { + return url.Contains(SpotifyUrl); + } + } + + public enum SearchType + { + None, + StringSearch, + VideoURL, + VideoPlaylistURL, + YoutubePlaylist, + SpotifyTrack, + SpotifyAlbum, + SpotifyPlaylist, + SpotifyArtist + } +} diff --git a/Kasbot.APP/Services/Internal/YoutubeService.cs b/Kasbot.APP/Services/Internal/YoutubeService.cs index 9c91b61..859c0c0 100644 --- a/Kasbot.APP/Services/Internal/YoutubeService.cs +++ b/Kasbot.APP/Services/Internal/YoutubeService.cs @@ -1,34 +1,37 @@ -using Discord.WebSocket; -using YoutubeExplode.Videos; +using Kasbot.App.Services.Internal; +using Serilog; using YoutubeExplode; -using Discord.Rest; +using YoutubeExplode.Videos; using YoutubeExplode.Videos.Streams; -using Kasbot.Models; namespace Kasbot.Services.Internal { public class YoutubeService { - public YoutubeService() - { + private ILogger Logger { get; set; } + public YoutubeService(ILogger logger) + { + this.Logger = logger; } - public async Task DownloadPlaylistMetadataFromYoutube(SocketUserMessage message, string search) + public async Task FetchPlaylist(Media rawMedia) { var collection = new MediaCollection(); var youtube = new YoutubeClient(); - var playlistInfo = await youtube.Playlists.GetAsync(search); - await youtube.Playlists.GetVideosAsync(search).ForEachAsync(videoId => + Logger.Debug($"Fetching playlist from YouTube: {rawMedia.Search}"); + + var playlistInfo = await youtube.Playlists.GetAsync(rawMedia.Search); + await youtube.Playlists.GetVideosAsync(rawMedia.Search).ForEachAsync(videoId => { var media = new Media { Name = videoId.Title, Length = videoId.Duration ?? new TimeSpan(0), VideoId = videoId.Id, - Message = message, - Flags = new Flags() + Message = rawMedia.Message, + Flags = rawMedia.Flags }; collection.Medias.Add(media); @@ -36,11 +39,15 @@ namespace Kasbot.Services.Internal collection.CollectionName = playlistInfo.Title; + Logger.Debug($"Fetched playlist from YouTube: {rawMedia.Search}"); + return collection; } - public async Task DownloadMetadataFromYoutube(Media media) + public async Task FetchSingleMedia(Media media) { + Logger.Debug($"Fetching single media: {media.Search}"); + var youtube = new YoutubeClient(); IVideo? videoId; @@ -52,9 +59,12 @@ namespace Kasbot.Services.Internal if (videoId == null) { + Logger.Error($"No video found for \"{media.Search}\"."); return media; } + Logger.Debug($"Found video: {videoId.Title}"); + media.Name = videoId.Title; media.Length = videoId.Duration ?? new TimeSpan(0); media.VideoId = videoId.Id; @@ -82,63 +92,5 @@ namespace Kasbot.Services.Internal return memoryStream; } - - public SearchType GetSearchType(string query) - { - if (string.IsNullOrEmpty(query)) - return SearchType.None; - - if (query.StartsWith("http://") || query.StartsWith("https://")) - { - if (query.Contains("playlist?list=")) - return SearchType.PlaylistURL; - - // need to add 'else if' for ChannelURL - - return SearchType.VideoURL; - } - - return SearchType.StringSearch; - } - } - - public enum SearchType - { - None, - StringSearch, - VideoURL, - PlaylistURL, - ChannelURL - } - - public class Media - { - public string Search { get; set; } - - public string Name { get; set; } - public TimeSpan Length { get; set; } - public Flags Flags { get; set; } - - public VideoId? VideoId { get; set; } - public RestUserMessage PlayMessage { get; set; } - public RestUserMessage? QueueMessage { get; set; } - - private SocketUserMessage message; - public SocketUserMessage Message - { - get => message; - set - { - message = value; - Channel = value.Channel; - } - } - public ISocketMessageChannel Channel { get; private set; } - } - - public class MediaCollection - { - public string CollectionName { get; set; } - public List Medias { get; set; } = new List(); } } diff --git a/Kasbot.APP/Services/PlayerService.cs b/Kasbot.APP/Services/PlayerService.cs index dbf656c..4c96e21 100644 --- a/Kasbot.APP/Services/PlayerService.cs +++ b/Kasbot.APP/Services/PlayerService.cs @@ -1,24 +1,28 @@ using Discord; using Discord.Audio; using Discord.Commands; +using Kasbot.App.Services.Internal; using Kasbot.Extensions; using Kasbot.Models; using Kasbot.Services.Internal; +using Serilog; namespace Kasbot.Services { public class PlayerService { - public Dictionary Clients { get; set; } - public YoutubeService YoutubeService { get; set; } - public AudioService AudioService { get; set; } + private Dictionary Clients { get; set; } + private AudioService AudioService { get; set; } + private MediaService MediaService { get; set; } + private ILogger Logger { get; set; } - public PlayerService(YoutubeService youtubeService, AudioService audioService) + public PlayerService(AudioService audioService, MediaService mediaService, ILogger logger) { - YoutubeService = youtubeService; - AudioService = audioService; - Clients = new Dictionary(); + + AudioService = audioService; + MediaService = mediaService; + this.Logger = logger; } private async Task CreateConnection(ulong guildId, IVoiceChannel voiceChannel) @@ -42,9 +46,9 @@ namespace Kasbot.Services var media = new Media() { Message = Context.Message, - Search = arguments, + Search = arguments.Trim(), Flags = flags, - Name = "", + Name = string.Empty, }; var guildId = Context.Guild.Id; var userVoiceChannel = (Context.User as IVoiceState).VoiceChannel; @@ -69,46 +73,49 @@ namespace Kasbot.Services { var startPlay = conn.Queue.Count == 0; - media.Search.Trim(); + var mediaType = UrlResolver.GetSearchType(media.Search); - switch (YoutubeService.GetSearchType(media.Search)) + Logger.Debug($"Enqueueing {media.Search} as {mediaType}"); + + switch (mediaType) { case SearchType.StringSearch: case SearchType.VideoURL: - media = await YoutubeService.DownloadMetadataFromYoutube(media); + case SearchType.SpotifyTrack: + Logger.Debug($"Fetching {media.Search} as {mediaType}"); - if (media.VideoId == null) - { - await media.Channel.SendTemporaryMessageAsync($"No video found for \"{media.Search}\"."); - return; - } + media = await MediaService.FetchSingleMedia(media, mediaType); - conn.Queue.Enqueue(media); - if (startPlay) - await PlayNext(guildId); - else + if (!startPlay && !media.Flags.Silent) { var message = $"Queued **{media.Name}** *({media.Length.TotalMinutes:00}:{media.Length.Seconds:00})*"; media.QueueMessage = await media.Channel.SendMessageAsync(message); } - break; - case SearchType.ChannelURL: - case SearchType.PlaylistURL: - var collection = await YoutubeService.DownloadPlaylistMetadataFromYoutube(media.Message, media.Search); + Logger.Debug($"Enqueueing {media.Search} as {mediaType}"); - collection.Medias.ForEach(m => conn.Queue.Enqueue(m)); - - await media.Channel.SendMessageAsync($"Queued **{collection.Medias.Count}** items from *{collection.CollectionName}* playlist."); - - if (startPlay) - await PlayNext(guildId); + conn.Queue.Enqueue(media); break; - case SearchType.None: - default: + case SearchType.VideoPlaylistURL: + case SearchType.YoutubePlaylist: + case SearchType.SpotifyPlaylist: + case SearchType.SpotifyAlbum: + Logger.Debug($"Fetching {media.Search} as {mediaType}"); + + var mediaCollection = await MediaService.FetchMediaCollection(media, mediaType); + + mediaCollection.Medias.ForEach(m => conn.Queue.Enqueue(m)); + + Logger.Debug($"Enqueueing {media.Search} as {mediaType}"); + + await media.Channel.SendMessageAsync($"Queued **{mediaCollection.Medias.Count}** items from *{mediaCollection.CollectionName}* playlist."); + break; } + + if (startPlay) + await PlayNext(guildId); } private async Task PlayNext(ulong guildId) @@ -134,16 +141,23 @@ namespace Kasbot.Services await CreateConnection(guildId, voiceChannel); } - var mp3Stream = await YoutubeService.DownloadAudioFromYoutube(nextMedia); + Logger.Debug($"Downloading {nextMedia.Name}"); + + var mp3Stream = await MediaService.DownloadAudioFromYoutube(nextMedia); + + Logger.Debug($"Playing {nextMedia.Name}"); if (mp3Stream == null) { + Logger.Error($"Failed to download {nextMedia.Name}"); await Stop(guildId); return; } var audioClient = Clients[guildId].AudioClient; + Logger.Information($"Playing {nextMedia.Name}"); + if (!nextMedia.Flags.Silent) { var message = $"⏯ Playing: **{nextMedia.Name}** *({nextMedia.Length.TotalMinutes:00}:{nextMedia.Length.Seconds:00})*"; @@ -164,6 +178,7 @@ namespace Kasbot.Services { if (ac.Exception != null) { + Logger.Error(ac.Exception, $"Error in stream: {ac.Exception.Message}"); await nextMedia.Channel.SendTemporaryMessageAsync("Error in stream: " + ac.Exception.ToString()); } }); diff --git a/Kasbot.APP/appsettings.json b/Kasbot.APP/appsettings.json new file mode 100644 index 0000000..5e0b372 --- /dev/null +++ b/Kasbot.APP/appsettings.json @@ -0,0 +1,6 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": "Debug" + } +} \ No newline at end of file