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