using EllieBot.Db.Models; using EllieBot.Modules.Music.Resolvers; using System.Diagnostics.CodeAnalysis; namespace EllieBot.Modules.Music.Services; public sealed class MusicService : IMusicService, IPlaceholderProvider { private readonly AyuVoiceStateService _voiceStateService; private readonly ITrackResolveProvider _trackResolveProvider; private readonly DbService _db; private readonly IYoutubeResolverFactory _ytResolver; private readonly ILocalTrackResolver _localResolver; private readonly DiscordSocketClient _client; private readonly IBotStrings _strings; private readonly IGoogleApiService _googleApiService; private readonly YtLoader _ytLoader; private readonly IMessageSenderService _sender; private readonly ConcurrentDictionary<ulong, IMusicPlayer> _players; private readonly ConcurrentDictionary<ulong, (ITextChannel Default, ITextChannel? Override)> _outputChannels; private readonly ConcurrentDictionary<ulong, MusicPlayerSettings> _settings; public MusicService( AyuVoiceStateService voiceStateService, ITrackResolveProvider trackResolveProvider, DbService db, IYoutubeResolverFactory ytResolver, ILocalTrackResolver localResolver, DiscordSocketClient client, IBotStrings strings, IGoogleApiService googleApiService, YtLoader ytLoader, IMessageSenderService sender) { _voiceStateService = voiceStateService; _trackResolveProvider = trackResolveProvider; _db = db; _ytResolver = ytResolver; _localResolver = localResolver; _client = client; _strings = strings; _googleApiService = googleApiService; _ytLoader = ytLoader; _sender = sender; _players = new(); _outputChannels = new ConcurrentDictionary<ulong, (ITextChannel, ITextChannel?)>(); _settings = new(); _client.LeftGuild += ClientOnLeftGuild; } private void DisposeMusicPlayer(IMusicPlayer musicPlayer) { musicPlayer.Kill(); _ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose()); } private void RemoveMusicPlayer(ulong guildId) { _outputChannels.TryRemove(guildId, out _); if (_players.TryRemove(guildId, out var mp)) DisposeMusicPlayer(mp); } private Task ClientOnLeftGuild(SocketGuild guild) { RemoveMusicPlayer(guild.Id); return Task.CompletedTask; } public async Task LeaveVoiceChannelAsync(ulong guildId) { RemoveMusicPlayer(guildId); await _voiceStateService.LeaveVoiceChannel(guildId); } public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId) => _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId); public async Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel) { var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel); if (newPLayer is null) return null; return _players.GetOrAdd(contextChannel.GuildId, newPLayer); } public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer) => _players.TryGetValue(guildId, out musicPlayer); public async Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer) { var count = 0; await foreach (var track in _ytResolver.GetYoutubeResolver().ResolveTracksFromPlaylistAsync(query)) { if (mp.IsKilled) break; mp.EnqueueTrack(track, queuer); ++count; } return count; } public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer) { await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath)) { if (mp.IsKilled) break; mp.EnqueueTrack(track, queuer); } } private async Task<IMusicPlayer?> CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel) { var queue = new MusicQueue(); var resolver = _trackResolveProvider; if (!_voiceStateService.TryGetProxy(guildId, out var proxy)) return null; var settings = await GetSettingsInternalAsync(guildId); ITextChannel? overrideChannel = null; if (settings.MusicChannelId is { } channelId) { overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId); if (overrideChannel is null) Log.Warning("Saved music output channel doesn't exist, falling back to current channel"); } _outputChannels[guildId] = (defaultChannel, overrideChannel); var mp = new MusicPlayer(queue, resolver, _ytResolver, proxy, _googleApiService, settings.QualityPreset, settings.AutoPlay); mp.SetRepeat(settings.PlayerRepeat); if (settings.Volume is >= 0 and <= 100) mp.SetVolume(settings.Volume); else Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume); mp.OnCompleted += OnTrackCompleted(guildId); mp.OnStarted += OnTrackStarted(guildId); mp.OnQueueStopped += OnQueueStopped(guildId); return mp; } public async Task<IUserMessage?> SendToOutputAsync(ulong guildId, EmbedBuilder embed) { if (_outputChannels.TryGetValue(guildId, out var chan)) { var msg = await _sender.Response(chan.Override ?? chan.Default) .Embed(embed) .SendAsync(); return msg; } return null; } private Func<IMusicPlayer, IQueuedTrackInfo, Task> OnTrackCompleted(ulong guildId) { IUserMessage? lastFinishedMessage = null; return async (mp, trackInfo) => { _ = lastFinishedMessage?.DeleteAsync(); var embed = _sender.CreateEmbed(guildId) .WithOkColor() .WithAuthor(GetText(guildId, strs.finished_track), Music.MUSIC_ICON_URL) .WithDescription(trackInfo.PrettyName()) .WithFooter(trackInfo.PrettyTotalTime()); lastFinishedMessage = await SendToOutputAsync(guildId, embed); }; } private Func<IMusicPlayer, IQueuedTrackInfo, int, Task> OnTrackStarted(ulong guildId) { IUserMessage? lastPlayingMessage = null; return async (mp, trackInfo, index) => { _ = lastPlayingMessage?.DeleteAsync(); var embed = _sender.CreateEmbed(guildId) .WithOkColor() .WithAuthor(GetText(guildId, strs.playing_track(index + 1)), Music.MUSIC_ICON_URL) .WithDescription(trackInfo.PrettyName()) .WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}"); lastPlayingMessage = await SendToOutputAsync(guildId, embed); }; } private Func<IMusicPlayer, Task> OnQueueStopped(ulong guildId) => _ => { if (_settings.TryGetValue(guildId, out var settings)) { if (settings.AutoDisconnect) return LeaveVoiceChannelAsync(guildId); } return Task.CompletedTask; }; // this has to be done because dragging bot to another vc isn't supported yet public async Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId) { if (!TryGetMusicPlayer(guildId, out var mp)) return false; if (mp.IsStopped) { if (!_voiceStateService.TryGetProxy(guildId, out var proxy) || proxy.State == VoiceProxy.VoiceProxyState.Stopped) await JoinVoiceChannelAsync(guildId, voiceChannelId); } mp.Next(); return true; } private async Task<IList<(string Title, string Url, string Thumb)>> SearchYtLoaderVideosAsync(string query) { var result = await _ytLoader.LoadResultsAsync(query); return result.Select(x => (x.Title, x.Url, x.Thumb)).ToList(); } private async Task<IList<(string Title, string Url, string Thumb)>> SearchGoogleApiVideosAsync(string query) { var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5); return result.Select(x => (x.Name, x.Url, x.Thumbnail)).ToList(); } public async Task<IList<(string Title, string Url, string Thumbnail)>> SearchVideosAsync(string query) { try { IList<(string, string, string)> videos = await SearchYtLoaderVideosAsync(query); if (videos.Count > 0) return videos; } catch (Exception ex) { Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message); } try { return await SearchGoogleApiVideosAsync(query); } catch (Exception ex) { Log.Warning("Failed getting video results with Google Api. " + "Probably google api key missing: {ErrorMessage}", ex.Message); } return Array.Empty<(string, string, string)>(); } private string GetText(ulong guildId, LocStr str) => _strings.GetText(str, guildId); public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders() { // random track that's playing yield return ("%music.playing%", () => { var randomPlayingTrack = _players.Select(x => x.Value.GetCurrentTrack(out _)) .Where(x => x is not null) .Shuffle() .FirstOrDefault(); if (randomPlayingTrack is null) return "-"; return randomPlayingTrack.Title; } ); // number of servers currently listening to music yield return ("%music.servers%", () => { var count = _players.Select(x => x.Value.GetCurrentTrack(out _)).Count(x => x is not null); return count.ToString(); } ); yield return ("%music.queued%", () => { var count = _players.Sum(x => x.Value.GetQueuedTracks().Count); return count.ToString(); } ); } #region Settings private async Task<MusicPlayerSettings> GetSettingsInternalAsync(ulong guildId) { if (_settings.TryGetValue(guildId, out var settings)) return settings; await using var uow = _db.GetDbContext(); var toReturn = _settings[guildId] = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId); await uow.SaveChangesAsync(); return toReturn; } private async Task ModifySettingsInternalAsync<TState>( ulong guildId, Action<MusicPlayerSettings, TState> action, TState state) { await using var uow = _db.GetDbContext(); var ms = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId); action(ms, state); await uow.SaveChangesAsync(); _settings[guildId] = ms; } public async Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId) { if (channelId is null) { await UnsetMusicChannelAsync(guildId); return true; } var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value); if (channel is null) return false; await ModifySettingsInternalAsync(guildId, (settings, chId) => { settings.MusicChannelId = chId; }, channelId); _outputChannels.AddOrUpdate(guildId, (channel, channel), (_, old) => (old.Default, channel)); return true; } public async Task UnsetMusicChannelAsync(ulong guildId) { await ModifySettingsInternalAsync(guildId, (settings, _) => { settings.MusicChannelId = null; }, (ulong?)null); if (_outputChannels.TryGetValue(guildId, out var old)) _outputChannels[guildId] = (old.Default, null); } public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType) { await ModifySettingsInternalAsync(guildId, (settings, type) => { settings.PlayerRepeat = type; }, repeatType); if (TryGetMusicPlayer(guildId, out var mp)) mp.SetRepeat(repeatType); } public async Task SetVolumeAsync(ulong guildId, int value) { if (value is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(value)); await ModifySettingsInternalAsync(guildId, (settings, newValue) => { settings.Volume = newValue; }, value); if (TryGetMusicPlayer(guildId, out var mp)) mp.SetVolume(value); } public async Task<bool> ToggleAutoDisconnectAsync(ulong guildId) { var newState = false; await ModifySettingsInternalAsync(guildId, (settings, _) => { newState = settings.AutoDisconnect = !settings.AutoDisconnect; }, default(object)); return newState; } public async Task<QualityPreset> GetMusicQualityAsync(ulong guildId) { await using var uow = _db.GetDbContext(); var settings = await uow.Set<MusicPlayerSettings>().ForGuildAsync(guildId); return settings.QualityPreset; } public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset) => ModifySettingsInternalAsync(guildId, (settings, _) => { settings.QualityPreset = preset; }, preset); public async Task<bool> ToggleQueueAutoPlayAsync(ulong guildId) { var newValue = false; await ModifySettingsInternalAsync(guildId, (settings, _) => newValue = settings.AutoPlay = !settings.AutoPlay, false); if (TryGetMusicPlayer(guildId, out var mp)) mp.AutoPlay = newValue; return newValue; } public Task<bool> FairplayAsync(ulong guildId) { if (TryGetMusicPlayer(guildId, out var mp)) { mp.SetFairplay(); return Task.FromResult(true); } return Task.FromResult(false); } public async Task<IQueuedTrackInfo?> RemoveLastQueuedTrackAsync(ulong guildId) { if (TryGetMusicPlayer(guildId, out var mp)) { var last = await mp.RemoveLastQueuedTrack(); return last; } return null; } #endregion }