diff --git a/src/EllieBot/Modules/Music/Music.cs b/src/EllieBot/Modules/Music/Music.cs
new file mode 100644
index 0000000..3b1393c
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Music.cs
@@ -0,0 +1,755 @@
+#nullable disable
+using EllieBot.Modules.Music.Services;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Music;
+
+[NoPublicBot]
+public sealed partial class Music : EllieModule<IMusicService>
+{
+    public enum All { All = -1 }
+
+    public enum InputRepeatType
+    {
+        N = 0, No = 0, None = 0,
+        T = 1, Track = 1, S = 1, Song = 1,
+        Q = 2, Queue = 2, Playlist = 2, Pl = 2
+    }
+
+    public const string MUSIC_ICON_URL = "https://i.imgur.com/nhKS3PT.png";
+
+    private const int LQ_ITEMS_PER_PAGE = 9;
+
+    private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1);
+    private readonly ILogCommandService _logService;
+
+    public Music(ILogCommandService logService)
+        => _logService = logService;
+
+    private async Task<bool> ValidateAsync()
+    {
+        var user = (IGuildUser)ctx.User;
+        var userVoiceChannelId = user.VoiceChannel?.Id;
+
+        if (userVoiceChannelId is null)
+        {
+            await Response().Error(strs.must_be_in_voice).SendAsync();
+            return false;
+        }
+
+        var currentUser = await ctx.Guild.GetCurrentUserAsync();
+        if (currentUser.VoiceChannel?.Id != userVoiceChannelId)
+        {
+            await Response().Error(strs.not_with_bot_in_voice).SendAsync();
+            return false;
+        }
+
+        return true;
+    }
+
+    private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
+    {
+        botUser ??= await ctx.Guild.GetCurrentUserAsync();
+        await _voiceChannelLock.WaitAsync();
+        try
+        {
+            if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
+                await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
+        }
+        finally
+        {
+            _voiceChannelLock.Release();
+        }
+    }
+
+    private async Task<bool> QueuePreconditionInternalAsync()
+    {
+        var user = (IGuildUser)ctx.User;
+        var voiceChannelId = user.VoiceChannel?.Id;
+
+        if (voiceChannelId is null)
+        {
+            await Response().Error(strs.must_be_in_voice).SendAsync();
+            return false;
+        }
+
+        _ = ctx.Channel.TriggerTypingAsync();
+
+        var botUser = await ctx.Guild.GetCurrentUserAsync();
+        await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
+
+        if (botUser.VoiceChannel?.Id != voiceChannelId)
+        {
+            await Response().Error(strs.not_with_bot_in_voice).SendAsync();
+            return false;
+        }
+
+        return true;
+    }
+
+    private async Task QueueByQuery(string query, bool asNext = false, MusicPlatform? forcePlatform = null)
+    {
+        var succ = await QueuePreconditionInternalAsync();
+        if (!succ)
+            return;
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query, ctx.User.ToString(), asNext, forcePlatform);
+        if (trackInfo is null)
+        {
+            await Response().Error(strs.track_not_found).SendAsync();
+            return;
+        }
+
+        try
+        {
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL)
+                               .WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ")
+                               .WithFooter(trackInfo.Platform.ToString());
+
+            if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail))
+                embed.WithThumbnailUrl(trackInfo.Thumbnail);
+
+            var queuedMessage = await _service.SendToOutputAsync(ctx.Guild.Id, embed);
+            queuedMessage?.DeleteAfter(10, _logService);
+            if (mp.IsStopped)
+            {
+                var msg = await Response().Pending(strs.queue_stopped(Format.Code(prefix + "play"))).SendAsync();
+                msg.DeleteAfter(10, _logService);
+            }
+        }
+        catch
+        {
+            // ignored
+        }
+    }
+
+    private async Task MoveToIndex(int index)
+    {
+        if (--index < 0)
+            return;
+
+        var succ = await QueuePreconditionInternalAsync();
+        if (!succ)
+            return;
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        mp.MoveTo(index);
+    }
+
+    // join vc
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Join()
+    {
+        var user = (IGuildUser)ctx.User;
+
+        var voiceChannelId = user.VoiceChannel?.Id;
+
+        if (voiceChannelId is null)
+        {
+            await Response().Error(strs.must_be_in_voice).SendAsync();
+            return;
+        }
+
+        await _service.JoinVoiceChannelAsync(user.GuildId, voiceChannelId.Value);
+    }
+
+    // leave vc (destroy)
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Destroy()
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        await _service.LeaveVoiceChannelAsync(ctx.Guild.Id);
+    }
+
+    // play - no args = next
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(2)]
+    public Task Play()
+        => Next();
+
+    // play - index = skip to that index
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(1)]
+    public Task Play(int index)
+        => MoveToIndex(index);
+
+    // play - query = q(query)
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(0)]
+    public Task Play([Leftover] string query)
+        => QueueByQuery(query);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public Task Queue([Leftover] string query)
+        => QueueByQuery(query);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public Task QueueNext([Leftover] string query)
+        => QueueByQuery(query, true);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Volume(int vol)
+    {
+        if (vol is < 0 or > 100)
+        {
+            await Response().Error(strs.volume_input_invalid).SendAsync();
+            return;
+        }
+
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        await _service.SetVolumeAsync(ctx.Guild.Id, vol);
+        await Response().Confirm(strs.volume_set(vol)).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Next()
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        var success = await _service.PlayAsync(ctx.Guild.Id, ((IGuildUser)ctx.User).VoiceChannel.Id);
+        if (!success)
+            await Response().Error(strs.no_player).SendAsync();
+    }
+
+    // list queue, relevant page
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task ListQueue()
+    {
+        // show page with the current track
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        await ListQueue((mp.CurrentIndex / LQ_ITEMS_PER_PAGE) + 1);
+    }
+
+    // list queue, specify page
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task ListQueue(int page)
+    {
+        if (--page < 0)
+            return;
+
+        IReadOnlyCollection<IQueuedTrackInfo> tracks;
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || (tracks = mp.GetQueuedTracks()).Count == 0)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        EmbedBuilder PrintAction(IReadOnlyList<IQueuedTrackInfo> tracks, int curPage)
+        {
+            var desc = string.Empty;
+            var current = mp.GetCurrentTrack(out var currentIndex);
+            if (current is not null)
+                desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc;
+
+            var repeatType = mp.Repeat;
+            var add = string.Empty;
+            if (mp.IsStopped)
+                add += Format.Bold(GetText(strs.queue_stopped(Format.Code(prefix + "play")))) + "\n";
+            // var mps = mp.MaxPlaytimeSeconds;
+            // if (mps > 0)
+            //     add += Format.Bold(GetText(strs.song_skips_after(TimeSpan.FromSeconds(mps).ToString("HH\\:mm\\:ss")))) + "\n";
+            if (repeatType == PlayerRepeatType.Track)
+                add += "🔂 " + GetText(strs.repeating_track) + "\n";
+            else
+            {
+                if (mp.AutoPlay)
+                    add += "↪ " + GetText(strs.autoplaying) + "\n";
+                // if (mp.FairPlay && !mp.Autoplay)
+                //     add += " " + GetText(strs.fairplay) + "\n";
+                if (repeatType == PlayerRepeatType.Queue)
+                    add += "🔁 " + GetText(strs.repeating_queue) + "\n";
+            }
+
+
+            desc += tracks
+                    .Select((v, index) =>
+                    {
+                        index += LQ_ITEMS_PER_PAGE * curPage;
+                        if (index == currentIndex)
+                            return $"**⇒**`{index + 1}.` {v.PrettyFullName()}";
+
+                        return $"`{index + 1}.` {v.PrettyFullName()}";
+                    })
+                    .Join('\n');
+
+            if (!string.IsNullOrWhiteSpace(add))
+                desc = add + "\n" + desc;
+
+            var embed = _sender.CreateEmbed()
+                               .WithAuthor(
+                                   GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)),
+                                   MUSIC_ICON_URL)
+                               .WithDescription(desc)
+                               .WithFooter(
+                                   $"  {mp.PrettyVolume()}  |  🎶 {tracks.Count}  |  ⌛ {mp.PrettyTotalTime()}  ")
+                               .WithOkColor();
+
+            return embed;
+        }
+
+        await Response()
+              .Paginated()
+              .Items(tracks)
+              .PageSize(LQ_ITEMS_PER_PAGE)
+              .CurrentPage(page)
+              .AddFooter(false)
+              .Page(PrintAction)
+              .SendAsync();
+    }
+
+    // search
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task QueueSearch([Leftover] string query)
+    {
+        _ = ctx.Channel.TriggerTypingAsync();
+
+        var videos = await _service.SearchVideosAsync(query);
+
+        if (videos.Count == 0)
+        {
+            await Response().Error(strs.track_not_found).SendAsync();
+            return;
+        }
+
+
+        var embeds = videos.Select((x, i) => _sender.CreateEmbed()
+                                                    .WithOkColor()
+                                                    .WithThumbnailUrl(x.Thumbnail)
+                                                    .WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}"))
+                           .ToList();
+
+        var msg = await Response()
+                        .Text(strs.queue_search_results)
+                        .Embeds(embeds)
+                        .SendAsync();
+
+        try
+        {
+            var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _));
+            if (input is null || !int.TryParse(input, out var index) || (index -= 1) < 0 || index >= videos.Count)
+            {
+                _logService.AddDeleteIgnore(msg.Id);
+                try
+                {
+                    await msg.DeleteAsync();
+                }
+                catch
+                {
+                }
+
+                return;
+            }
+
+            query = videos[index].Url;
+
+            await Play(query);
+        }
+        finally
+        {
+            _logService.AddDeleteIgnore(msg.Id);
+            try
+            {
+                await msg.DeleteAsync();
+            }
+            catch
+            {
+            }
+        }
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(1)]
+    public async Task TrackRemove(int index)
+    {
+        if (index < 1)
+        {
+            await Response().Error(strs.removed_track_error).SendAsync();
+            return;
+        }
+
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        if (!mp.TryRemoveTrackAt(index - 1, out var track))
+        {
+            await Response().Error(strs.removed_track_error).SendAsync();
+            return;
+        }
+
+        var embed = _sender.CreateEmbed()
+                           .WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL)
+                           .WithDescription(track.PrettyName())
+                           .WithFooter(track.PrettyInfo())
+                           .WithErrorColor();
+
+        await _service.SendToOutputAsync(ctx.Guild.Id, embed);
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [Priority(0)]
+    public async Task TrackRemove(All _ = All.All)
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        mp.Clear();
+        await Response().Confirm(strs.queue_cleared).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Stop()
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        mp.Stop();
+    }
+
+    private PlayerRepeatType InputToDbType(InputRepeatType type)
+        => type switch
+        {
+            InputRepeatType.None => PlayerRepeatType.None,
+            InputRepeatType.Queue => PlayerRepeatType.Queue,
+            InputRepeatType.Track => PlayerRepeatType.Track,
+            _ => PlayerRepeatType.Queue
+        };
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task QueueRepeat(InputRepeatType type = InputRepeatType.Queue)
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        await _service.SetRepeatAsync(ctx.Guild.Id, InputToDbType(type));
+
+        if (type == InputRepeatType.None)
+            await Response().Confirm(strs.repeating_none).SendAsync();
+        else if (type == InputRepeatType.Queue)
+            await Response().Confirm(strs.repeating_queue).SendAsync();
+        else
+            await Response().Confirm(strs.repeating_track).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Pause()
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || mp.GetCurrentTrack(out _) is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        mp.TogglePause();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public Task Radio(string radioLink)
+        => QueueByQuery(radioLink, false, MusicPlatform.Radio);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    public Task Local([Leftover] string path)
+        => QueueByQuery(path, false, MusicPlatform.Local);
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [OwnerOnly]
+    public async Task LocalPlaylist([Leftover] string dirPath)
+    {
+        if (string.IsNullOrWhiteSpace(dirPath))
+            return;
+
+        var user = (IGuildUser)ctx.User;
+        var voiceChannelId = user.VoiceChannel?.Id;
+
+        if (voiceChannelId is null)
+        {
+            await Response().Error(strs.must_be_in_voice).SendAsync();
+            return;
+        }
+
+        _ = ctx.Channel.TriggerTypingAsync();
+
+        var botUser = await ctx.Guild.GetCurrentUserAsync();
+        await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
+
+        if (botUser.VoiceChannel?.Id != voiceChannelId)
+        {
+            await Response().Error(strs.not_with_bot_in_voice).SendAsync();
+            return;
+        }
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        await _service.EnqueueDirectoryAsync(mp, dirPath, ctx.User.ToString());
+
+        await Response().Confirm(strs.dir_queue_complete).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task TrackMove(int from, int to)
+    {
+        if (--from < 0 || --to < 0 || from == to)
+        {
+            await Response().Error(strs.invalid_input).SendAsync();
+            return;
+        }
+
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        var track = mp.MoveTrack(from, to);
+        if (track is null)
+        {
+            await Response().Error(strs.invalid_input).SendAsync();
+            return;
+        }
+
+        var embed = _sender.CreateEmbed()
+                           .WithTitle(track.Title.TrimTo(65))
+                           .WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL)
+                           .AddField(GetText(strs.from_position), $"#{from + 1}", true)
+                           .AddField(GetText(strs.to_position), $"#{to + 1}", true)
+                           .WithOkColor();
+
+        if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute))
+            embed.WithUrl(track.Url);
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Playlist([Leftover] string playlistQuery)
+    {
+        if (string.IsNullOrWhiteSpace(playlistQuery))
+            return;
+
+        var succ = await QueuePreconditionInternalAsync();
+        if (!succ)
+            return;
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        _ = ctx.Channel.TriggerTypingAsync();
+
+
+        var queuedCount = await _service.EnqueueYoutubePlaylistAsync(mp, playlistQuery, ctx.User.ToString());
+        if (queuedCount == 0)
+        {
+            await Response().Error(strs.no_search_results).SendAsync();
+            return;
+        }
+
+        await ctx.OkAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task NowPlaying()
+    {
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        var currentTrack = mp.GetCurrentTrack(out _);
+        if (currentTrack is null)
+            return;
+
+        var embed = _sender.CreateEmbed()
+                           .WithOkColor()
+                           .WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL)
+                           .WithDescription(currentTrack.PrettyName())
+                           .WithThumbnailUrl(currentTrack.Thumbnail)
+                           .WithFooter(
+                               $"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}");
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task PlaylistShuffle()
+    {
+        var valid = await ValidateAsync();
+        if (!valid)
+            return;
+
+        var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+        if (mp is null)
+        {
+            await Response().Error(strs.no_player).SendAsync();
+            return;
+        }
+
+        mp.ShuffleQueue();
+        await Response().Confirm(strs.queue_shuffled).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [UserPerm(GuildPerm.ManageMessages)]
+    public async Task SetMusicChannel()
+    {
+        await _service.SetMusicChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
+
+        await Response().Confirm(strs.set_music_channel).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [UserPerm(GuildPerm.ManageMessages)]
+    public async Task UnsetMusicChannel()
+    {
+        await _service.SetMusicChannelAsync(ctx.Guild.Id, null);
+
+        await Response().Confirm(strs.unset_music_channel).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task AutoDisconnect()
+    {
+        var newState = await _service.ToggleAutoDisconnectAsync(ctx.Guild.Id);
+
+        if (newState)
+            await Response().Confirm(strs.autodc_enable).SendAsync();
+        else
+            await Response().Confirm(strs.autodc_disable).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [UserPerm(GuildPerm.Administrator)]
+    public async Task MusicQuality()
+    {
+        var quality = await _service.GetMusicQualityAsync(ctx.Guild.Id);
+        await Response().Confirm(strs.current_music_quality(Format.Bold(quality.ToString()))).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    [UserPerm(GuildPerm.Administrator)]
+    public async Task MusicQuality(QualityPreset preset)
+    {
+        await _service.SetMusicQualityAsync(ctx.Guild.Id, preset);
+        await Response().Confirm(strs.music_quality_set(Format.Bold(preset.ToString()))).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task QueueAutoPlay()
+    {
+        var newValue = await _service.ToggleQueueAutoPlayAsync(ctx.Guild.Id);
+        if (newValue)
+            await Response().Confirm(strs.music_autoplay_on).SendAsync();
+        else
+            await Response().Confirm(strs.music_autoplay_off).SendAsync();
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task QueueFairplay()
+    {
+        var newValue = await _service.FairplayAsync(ctx.Guild.Id);
+        if (newValue)
+            await Response().Confirm(strs.music_fairplay).SendAsync();
+        else
+            await Response().Error(strs.no_player).SendAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/PlaylistCommands.cs b/src/EllieBot/Modules/Music/PlaylistCommands.cs
new file mode 100644
index 0000000..d64e931
--- /dev/null
+++ b/src/EllieBot/Modules/Music/PlaylistCommands.cs
@@ -0,0 +1,245 @@
+#nullable disable
+using LinqToDB;
+using EllieBot.Modules.Music.Services;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Music;
+
+public sealed partial class Music
+{
+    [Group]
+    public sealed partial class PlaylistCommands : EllieModule<IMusicService>
+    {
+        private static readonly SemaphoreSlim _playlistLock = new(1, 1);
+        private readonly DbService _db;
+        private readonly IBotCredentials _creds;
+
+        public PlaylistCommands(DbService db, IBotCredentials creds)
+        {
+            _db = db;
+            _creds = creds;
+        }
+
+        private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null)
+        {
+            botUser ??= await ctx.Guild.GetCurrentUserAsync();
+            await _voiceChannelLock.WaitAsync();
+            try
+            {
+                if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _))
+                    await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId);
+            }
+            finally
+            {
+                _voiceChannelLock.Release();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Playlists([Leftover] int num = 1)
+        {
+            if (num <= 0)
+                return;
+
+            List<MusicPlaylist> playlists;
+
+            await using (var uow = _db.GetDbContext())
+            {
+                playlists = uow.Set<MusicPlaylist>().GetPlaylistsOnPage(num);
+            }
+
+            var embed = _sender.CreateEmbed()
+                        .WithAuthor(GetText(strs.playlists_page(num)), MUSIC_ICON_URL)
+                        .WithDescription(string.Join("\n",
+                            playlists.Select(r => GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count)))))
+                        .WithOkColor();
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task DeletePlaylist([Leftover] int id)
+        {
+            var success = false;
+            try
+            {
+                await using var uow = _db.GetDbContext();
+                var pl = uow.Set<MusicPlaylist>().FirstOrDefault(x => x.Id == id);
+
+                if (pl is not null)
+                {
+                    if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id)
+                    {
+                        uow.Set<MusicPlaylist>().Remove(pl);
+                        await uow.SaveChangesAsync();
+                        success = true;
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Error deleting playlist");
+            }
+
+            if (!success)
+                await Response().Error(strs.playlist_delete_fail).SendAsync();
+            else
+                await Response().Confirm(strs.playlist_deleted).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task PlaylistShow(int id, int page = 1)
+        {
+            if (page-- < 1)
+                return;
+
+            MusicPlaylist mpl;
+            await using (var uow = _db.GetDbContext())
+            {
+                mpl = uow.Set<MusicPlaylist>().GetWithSongs(id);
+            }
+
+            await Response()
+                  .Paginated()
+                  .Items(mpl.Songs)
+                  .PageSize(20)
+                  .CurrentPage(page)
+                  .Page((items, _) =>
+                  {
+                      var i = 0;
+                      var str = string.Join("\n",
+                          items
+                              .Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`"));
+                      return _sender.CreateEmbed().WithTitle($"\"{mpl.Name}\" by {mpl.Author}")
+                                               .WithOkColor()
+                                               .WithDescription(str);
+                  })
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Save([Leftover] string name)
+        {
+            if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp))
+            {
+                await Response().Error(strs.no_player).SendAsync();
+                return;
+            }
+
+            var songs = mp.GetQueuedTracks()
+                          .Select(s => new PlaylistSong
+                          {
+                              Provider = s.Platform.ToString(),
+                              ProviderType = (MusicType)s.Platform,
+                              Title = s.Title,
+                              Query = s.Url
+                          })
+                          .ToList();
+
+            MusicPlaylist playlist;
+            await using (var uow = _db.GetDbContext())
+            {
+                playlist = new()
+                {
+                    Name = name,
+                    Author = ctx.User.Username,
+                    AuthorId = ctx.User.Id,
+                    Songs = songs.ToList()
+                };
+                uow.Set<MusicPlaylist>().Add(playlist);
+                await uow.SaveChangesAsync();
+            }
+
+            await Response()
+                  .Embed(_sender.CreateEmbed()
+                         .WithOkColor()
+                         .WithTitle(GetText(strs.playlist_saved))
+                         .AddField(GetText(strs.name), name)
+                         .AddField(GetText(strs.id), playlist.Id.ToString()))
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Load([Leftover] int id)
+        {
+            // expensive action, 1 at a time
+            await _playlistLock.WaitAsync();
+            try
+            {
+                var user = (IGuildUser)ctx.User;
+                var voiceChannelId = user.VoiceChannel?.Id;
+
+                if (voiceChannelId is null)
+                {
+                    await Response().Error(strs.must_be_in_voice).SendAsync();
+                    return;
+                }
+
+                _ = ctx.Channel.TriggerTypingAsync();
+
+                var botUser = await ctx.Guild.GetCurrentUserAsync();
+                await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser);
+
+                if (botUser.VoiceChannel?.Id != voiceChannelId)
+                {
+                    await Response().Error(strs.not_with_bot_in_voice).SendAsync();
+                    return;
+                }
+
+                var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel);
+                if (mp is null)
+                {
+                    await Response().Error(strs.no_player).SendAsync();
+                    return;
+                }
+
+                MusicPlaylist mpl;
+                await using (var uow = _db.GetDbContext())
+                {
+                    mpl = uow.Set<MusicPlaylist>().GetWithSongs(id);
+                }
+
+                if (mpl is null)
+                {
+                    await Response().Error(strs.playlist_id_not_found).SendAsync();
+                    return;
+                }
+
+                IUserMessage msg = null;
+                try
+                {
+                    msg = await Response()
+                                .Pending(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString())))
+                                .SendAsync();
+                }
+                catch (Exception)
+                {
+                }
+
+                await mp.EnqueueManyAsync(mpl.Songs.Select(x => (x.Query, (MusicPlatform)x.ProviderType)),
+                    ctx.User.ToString());
+
+                if (msg is not null)
+                    await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete));
+            }
+            finally
+            {
+                _playlistLock.Release();
+            }
+        }
+
+        [Cmd]
+        [OwnerOnly]
+        public async Task DeletePlaylists()
+        {
+            await using var uow = _db.GetDbContext();
+            await uow.Set<MusicPlaylist>().DeleteAsync();
+            await uow.SaveChangesAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs
new file mode 100644
index 0000000..ff47354
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs
@@ -0,0 +1,217 @@
+#nullable disable
+using EllieBot.Voice;
+using System.Reflection;
+
+namespace EllieBot.Modules.Music.Services;
+
+public sealed class AyuVoiceStateService : IEService
+{
+    // public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy);
+    // public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; };
+
+    private readonly ConcurrentDictionary<ulong, IVoiceProxy> _voiceProxies = new();
+    private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _voiceGatewayLocks = new();
+
+    private readonly DiscordSocketClient _client;
+    private readonly MethodInfo _sendVoiceStateUpdateMethodInfo;
+    private readonly object _dnetApiClient;
+    private readonly ulong _currentUserId;
+
+    public AyuVoiceStateService(DiscordSocketClient client)
+    {
+        _client = client;
+        _currentUserId = _client.CurrentUser.Id;
+
+        var prop = _client.GetType()
+                          .GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
+                          .First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient");
+        _dnetApiClient = prop.GetValue(_client, null);
+        _sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType()
+                                                        .GetMethod("SendVoiceStateUpdateAsync",
+                                                        [
+                                                            typeof(ulong), typeof(ulong?), typeof(bool),
+                                                                typeof(bool), typeof(RequestOptions)
+                                                        ]);
+
+        _client.LeftGuild += ClientOnLeftGuild;
+    }
+
+    private Task ClientOnLeftGuild(SocketGuild guild)
+    {
+        if (_voiceProxies.TryRemove(guild.Id, out var proxy))
+        {
+            proxy.StopGateway();
+            proxy.SetGateway(null);
+        }
+
+        return Task.CompletedTask;
+    }
+
+    private Task InvokeSendVoiceStateUpdateAsync(
+            ulong guildId,
+            ulong? channelId = null,
+            bool isDeafened = false,
+            bool isMuted = false)
+        // return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted);
+        => (Task)_sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient,
+            [guildId, channelId, isMuted, isDeafened, null]);
+
+    private Task SendLeaveVoiceChannelInternalAsync(ulong guildId)
+        => InvokeSendVoiceStateUpdateAsync(guildId);
+
+    private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId)
+        => InvokeSendVoiceStateUpdateAsync(guildId, channelId);
+
+    private SemaphoreSlim GetVoiceGatewayLock(ulong guildId)
+        => _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1));
+
+    private async Task LeaveVoiceChannelInternalAsync(ulong guildId)
+    {
+        var complete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
+        {
+            if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId && newState.VoiceChannel?.Id is null)
+                complete.TrySetResult(true);
+
+            return Task.CompletedTask;
+        }
+
+        try
+        {
+            _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
+
+            if (_voiceProxies.TryGetValue(guildId, out var proxy))
+            {
+                _ = proxy.StopGateway();
+                proxy.SetGateway(null);
+            }
+
+            await SendLeaveVoiceChannelInternalAsync(guildId);
+            await Task.WhenAny(Task.Delay(1500), complete.Task);
+        }
+        finally
+        {
+            _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
+        }
+    }
+
+    public async Task LeaveVoiceChannel(ulong guildId)
+    {
+        var gwLock = GetVoiceGatewayLock(guildId);
+        await gwLock.WaitAsync();
+        try
+        {
+            await LeaveVoiceChannelInternalAsync(guildId);
+        }
+        finally
+        {
+            gwLock.Release();
+        }
+    }
+
+    private async Task<IVoiceProxy> InternalConnectToVcAsync(ulong guildId, ulong channelId)
+    {
+        var voiceStateUpdatedSource =
+            new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
+        var voiceServerUpdatedSource =
+            new TaskCompletionSource<SocketVoiceServer>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState)
+        {
+            if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId)
+            {
+                if (newState.VoiceChannel?.Id == channelId)
+                    voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId);
+
+                voiceStateUpdatedSource.TrySetResult(null);
+            }
+
+            return Task.CompletedTask;
+        }
+
+        Task OnVoiceServerUpdated(SocketVoiceServer data)
+        {
+            if (data.Guild.Id == guildId)
+                voiceServerUpdatedSource.TrySetResult(data);
+
+            return Task.CompletedTask;
+        }
+
+        try
+        {
+            _client.VoiceServerUpdated += OnVoiceServerUpdated;
+            _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
+
+            await SendJoinVoiceChannelInternalAsync(guildId, channelId);
+
+            // create a delay task, how much to wait for gateway response
+            using var cts = new CancellationTokenSource();
+            var delayTask = Task.Delay(2500, cts.Token);
+
+            // either delay or successful voiceStateUpdate
+            var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task);
+            // either delay or successful voiceServerUpdate
+            var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task);
+
+            // wait for both to end (max 1s) and check if either of them is a delay task
+            var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask);
+            if (results[0] == delayTask || results[1] == delayTask)
+                // if either is delay, return null - connection unsuccessful
+                return null;
+            else
+                cts.Cancel();
+
+            // if both are succesful, that means we can safely get
+            // the values from  completion sources
+
+            var session = await voiceStateUpdatedSource.Task;
+
+            // session can be null. Means we disconnected, or connected to the wrong channel (?!)
+            if (session is null)
+                return null;
+
+            var voiceServerData = await voiceServerUpdatedSource.Task;
+
+            VoiceGateway CreateVoiceGatewayLocal()
+            {
+                return new(guildId, _currentUserId, session, voiceServerData.Token, voiceServerData.Endpoint);
+            }
+
+            var current = _voiceProxies.AddOrUpdate(guildId,
+                _ => new VoiceProxy(CreateVoiceGatewayLocal()),
+                (gid, currentProxy) =>
+                {
+                    _ = currentProxy.StopGateway();
+                    currentProxy.SetGateway(CreateVoiceGatewayLocal());
+                    return currentProxy;
+                });
+
+            _ = current.StartGateway(); // don't await, this blocks until gateway is closed
+            return current;
+        }
+        finally
+        {
+            _client.VoiceServerUpdated -= OnVoiceServerUpdated;
+            _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated;
+        }
+    }
+
+    public async Task<IVoiceProxy> JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true)
+    {
+        var gwLock = GetVoiceGatewayLock(guildId);
+        await gwLock.WaitAsync();
+        try
+        {
+            await LeaveVoiceChannelInternalAsync(guildId);
+            return await InternalConnectToVcAsync(guildId, channelId);
+        }
+        finally
+        {
+            gwLock.Release();
+        }
+    }
+
+    public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy)
+        => _voiceProxies.TryGetValue(guildId, out proxy);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/Services/IMusicService.cs b/src/EllieBot/Modules/Music/Services/IMusicService.cs
new file mode 100644
index 0000000..43bad99
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Services/IMusicService.cs
@@ -0,0 +1,36 @@
+using EllieBot.Db.Models;
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Modules.Music.Services;
+
+public interface IMusicService
+{
+    /// <summary>
+    ///     Leave voice channel in the specified guild if it's connected to one
+    /// </summary>
+    /// <param name="guildId">Id of the guild</param>
+    public Task LeaveVoiceChannelAsync(ulong guildId);
+
+    /// <summary>
+    ///     Joins the voice channel with the specified id
+    /// </summary>
+    /// <param name="guildId">Id of the guild where the voice channel is</param>
+    /// <param name="voiceChannelId">Id of the voice channel</param>
+    public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId);
+
+    Task<IMusicPlayer?> GetOrCreateMusicPlayerAsync(ITextChannel contextChannel);
+    bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer);
+    Task<int> EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer);
+    Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer);
+    Task<IUserMessage?> SendToOutputAsync(ulong guildId, EmbedBuilder embed);
+    Task<bool> PlayAsync(ulong guildId, ulong voiceChannelId);
+    Task<IList<(string Title, string Url, string Thumbnail)>> SearchVideosAsync(string query);
+    Task<bool> SetMusicChannelAsync(ulong guildId, ulong? channelId);
+    Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType);
+    Task SetVolumeAsync(ulong guildId, int value);
+    Task<bool> ToggleAutoDisconnectAsync(ulong guildId);
+    Task<QualityPreset> GetMusicQualityAsync(ulong guildId);
+    Task SetMusicQualityAsync(ulong guildId, QualityPreset preset);
+    Task<bool> ToggleQueueAutoPlayAsync(ulong guildId);
+    Task<bool> FairplayAsync(ulong guildId);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/Services/MusicService.cs b/src/EllieBot/Modules/Music/Services/MusicService.cs
new file mode 100644
index 0000000..89c848d
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Services/MusicService.cs
@@ -0,0 +1,438 @@
+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()
+                        .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()
+                        .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);
+    }
+
+    #endregion
+}
diff --git a/src/EllieBot/Modules/Music/Services/extractor/Misc.cs b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs
new file mode 100644
index 0000000..68c1fca
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs
@@ -0,0 +1,74 @@
+#nullable disable
+namespace EllieBot.Modules.Music.Services;
+
+public sealed partial class YtLoader
+{
+    public class InitRange
+    {
+        public string Start { get; set; }
+        public string End { get; set; }
+    }
+
+    public class IndexRange
+    {
+        public string Start { get; set; }
+        public string End { get; set; }
+    }
+
+    public class ColorInfo
+    {
+        public string Primaries { get; set; }
+        public string TransferCharacteristics { get; set; }
+        public string MatrixCoefficients { get; set; }
+    }
+
+    public class YtAdaptiveFormat
+    {
+        public int Itag { get; set; }
+        public string MimeType { get; set; }
+        public int Bitrate { get; set; }
+        public int Width { get; set; }
+        public int Height { get; set; }
+        public InitRange InitRange { get; set; }
+        public IndexRange IndexRange { get; set; }
+        public string LastModified { get; set; }
+        public string ContentLength { get; set; }
+        public string Quality { get; set; }
+        public int Fps { get; set; }
+        public string QualityLabel { get; set; }
+        public string ProjectionType { get; set; }
+        public int AverageBitrate { get; set; }
+        public ColorInfo ColorInfo { get; set; }
+        public string ApproxDurationMs { get; set; }
+        public string SignatureCipher { get; set; }
+    }
+
+    public abstract class TrackInfo
+    {
+        public abstract string Url { get; }
+        public abstract string Title { get; }
+        public abstract string Thumb { get; }
+        public abstract TimeSpan Duration { get; }
+    }
+
+    public sealed class YtTrackInfo : TrackInfo
+    {
+        private const string BASE_YOUTUBE_URL = "https://youtube.com/watch?v=";
+        public override string Url { get; }
+        public override string Title { get; }
+        public override string Thumb { get; }
+        public override TimeSpan Duration { get; }
+
+        private readonly string _videoId;
+
+        public YtTrackInfo(string title, string videoId, string thumb, TimeSpan duration)
+        {
+            Title = title;
+            Thumb = thumb;
+            Url = BASE_YOUTUBE_URL + videoId;
+            Duration = duration;
+
+            _videoId = videoId;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs
new file mode 100644
index 0000000..a91dd11
--- /dev/null
+++ b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs
@@ -0,0 +1,130 @@
+#nullable disable
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+
+namespace EllieBot.Modules.Music.Services;
+
+public sealed partial class YtLoader : IEService
+{
+    private static readonly byte[] _ytResultInitialData = Encoding.UTF8.GetBytes("var ytInitialData = ");
+    private static readonly byte[] _ytResultJsonEnd = Encoding.UTF8.GetBytes(";<");
+
+    private static readonly string[] _durationFormats =
+    [
+        @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss"
+    ];
+
+    private readonly IHttpClientFactory _httpFactory;
+
+    public YtLoader(IHttpClientFactory httpFactory)
+        => _httpFactory = httpFactory;
+
+    // public async Task<TrackInfo> LoadTrackByIdAsync(string videoId)
+    // {
+    //     using var http = new HttpClient();
+    //     http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1");
+    //     http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00");
+    //     http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
+    //
+    //     var responseString = await http.GetStringAsync($"https://youtube.com?" +
+    //                         $"pbj=1" +
+    //                         $"&hl=en" +
+    //                         $"&v=" + videoId);
+    //
+    //     var jsonDoc = JsonDocument.Parse(responseString).RootElement;
+    //     var elem = jsonDoc.EnumerateArray()
+    //         .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch");
+    //
+    //     var formatsJsonArray = elem.GetProperty("streamingdata")
+    //         .GetProperty("formats")
+    //         .GetRawText();
+    //     
+    //     var formats = JsonSerializer.Deserialize<List<YtAdaptiveFormat>>(formatsJsonArray);
+    //     var result = formats
+    //         .Where(x => x.MimeType.StartsWith("audio/"))
+    //         .OrderByDescending(x => x.Bitrate)
+    //         .FirstOrDefault();
+    //
+    //     if (result is null)
+    //         return null;
+    //
+    //     return new YtTrackInfo("1", "2", TimeSpan.Zero);
+    // }
+
+    public async Task<IList<TrackInfo>> LoadResultsAsync(string query)
+    {
+        query = Uri.EscapeDataString(query);
+
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;");
+
+        byte[] response;
+        try
+        {
+            response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}");
+        }
+        catch (HttpRequestException ex)
+        {
+            Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message);
+            return null;
+        }
+
+        // there is a lot of useless html above the script tag, however if html gets significantly reduced
+        // this will result in the json being cut off
+
+        var mem = GetScriptResponseSpan(response);
+        var root = JsonDocument.Parse(mem).RootElement;
+
+        using var tracksJsonItems = root
+                              .GetProperty("contents")
+                              .GetProperty("twoColumnSearchResultsRenderer")
+                              .GetProperty("primaryContents")
+                              .GetProperty("sectionListRenderer")
+                              .GetProperty("contents")[0]
+                              .GetProperty("itemSectionRenderer")
+                              .GetProperty("contents")
+                              .EnumerateArray();
+
+        var tracks = new List<TrackInfo>();
+        foreach (var track in tracksJsonItems)
+        {
+            if (!track.TryGetProperty("videoRenderer", out var elem))
+                continue;
+
+            var videoId = elem.GetProperty("videoId").GetString();
+            var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString();
+            var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString();
+            var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString();
+
+            if (!TimeSpan.TryParseExact(durationString,
+                    _durationFormats,
+                    CultureInfo.InvariantCulture,
+                    out var duration))
+            {
+                Log.Warning("Cannot parse duration: {DurationString}", durationString);
+                continue;
+            }
+
+            tracks.Add(new YtTrackInfo(title, videoId, thumb, duration));
+            if (tracks.Count >= 5)
+                break;
+        }
+
+        return tracks;
+    }
+
+    private Memory<byte> GetScriptResponseSpan(byte[] response)
+    {
+        var responseSpan = response.AsSpan()[140_000..];
+        var startIndex = responseSpan.IndexOf(_ytResultInitialData);
+        if (startIndex == -1)
+            return null; // FUTURE try selecting html
+        startIndex += _ytResultInitialData.Length;
+
+        var endIndex =
+            140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(_ytResultJsonEnd) + 20_000;
+        startIndex += 140_000;
+        return response.AsMemory(startIndex, endIndex - startIndex);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs
new file mode 100644
index 0000000..020e074
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs
@@ -0,0 +1,12 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public interface ICachableTrackData
+{
+    string Id { get; set; }
+    string Url { get; set; }
+    string Thumbnail { get; set; }
+    public TimeSpan Duration { get; }
+    MusicPlatform Platform { get; set; }
+    string Title { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs
new file mode 100644
index 0000000..f4ea2bf
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public interface ILocalTrackResolver : IPlatformQueryResolver
+{
+    IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs
new file mode 100644
index 0000000..a593a57
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs
@@ -0,0 +1,41 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Music;
+
+public interface IMusicPlayer : IDisposable
+{
+    float Volume { get; }
+    bool IsPaused { get; }
+    bool IsStopped { get; }
+    bool IsKilled { get; }
+    int CurrentIndex { get; }
+    public PlayerRepeatType Repeat { get; }
+    bool AutoPlay { get; set; }
+
+    void Stop();
+    void Clear();
+    IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks();
+    IQueuedTrackInfo? GetCurrentTrack(out int index);
+    void Next();
+    bool MoveTo(int index);
+    void SetVolume(int newVolume);
+
+    void Kill();
+    bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo);
+
+
+    Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
+        string query,
+        string queuer,
+        bool asNext,
+        MusicPlatform? forcePlatform = null);
+
+    Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer);
+    bool TogglePause();
+    IQueuedTrackInfo? MoveTrack(int from, int to);
+    void EnqueueTrack(ITrackInfo track, string queuer);
+    void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer);
+    void SetRepeat(PlayerRepeatType type);
+    void ShuffleQueue();
+    void SetFairplay();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IMusicQueue.cs b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs
new file mode 100644
index 0000000..5d4d24b
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs
@@ -0,0 +1,23 @@
+namespace EllieBot.Modules.Music;
+
+public interface IMusicQueue
+{
+    int Index { get; }
+    int Count { get; }
+    IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index);
+    IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index);
+
+    void EnqueueMany(IEnumerable<ITrackInfo> tracks, string queuer);
+
+    public IReadOnlyCollection<IQueuedTrackInfo> List();
+    IQueuedTrackInfo? GetCurrent(out int index);
+    void Advance();
+    void Clear();
+    bool SetIndex(int index);
+    bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent);
+    void RemoveCurrent();
+    IQueuedTrackInfo? MoveTrack(int from, int to);
+    void Shuffle(Random rng);
+    bool IsLast();
+    void ReorderFairly();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs
new file mode 100644
index 0000000..fa282ed
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Modules.Music;
+
+public interface IPlatformQueryResolver
+{
+    Task<ITrackInfo?> ResolveByQueryAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs
new file mode 100644
index 0000000..5093cd9
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public interface IQueuedTrackInfo : ITrackInfo
+{
+    public ITrackInfo TrackInfo { get; }
+
+    public string Queuer { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IRadioResolver.cs b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs
new file mode 100644
index 0000000..86a6ba5
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs
@@ -0,0 +1,6 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public interface IRadioResolver : IPlatformQueryResolver
+{
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/ITrackCacher.cs b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs
new file mode 100644
index 0000000..d55cd65
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs
@@ -0,0 +1,25 @@
+namespace EllieBot.Modules.Music;
+
+public interface ITrackCacher
+{
+    Task<string?> GetOrCreateStreamLink(
+        string id,
+        MusicPlatform platform,
+        Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory);
+
+    Task CacheTrackDataAsync(ICachableTrackData data);
+    Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform);
+    Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform);
+    Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data);
+
+    Task CacheStreamUrlAsync(
+        string id,
+        MusicPlatform platform,
+        string url,
+        TimeSpan expiry);
+
+    Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform);
+    Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids);
+    Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId);
+    Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/ITrackInfo.cs b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs
new file mode 100644
index 0000000..3525b59
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs
@@ -0,0 +1,11 @@
+namespace EllieBot.Modules.Music;
+
+public interface ITrackInfo
+{
+    public string Id => string.Empty;
+    public string Title { get; }
+    public string Url { get; }
+    public string Thumbnail { get; }
+    public TimeSpan Duration { get; }
+    public MusicPlatform Platform { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs
new file mode 100644
index 0000000..ae3d1e6
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Modules.Music;
+
+public interface ITrackResolveProvider
+{
+    Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs
new file mode 100644
index 0000000..d88e51c
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs
@@ -0,0 +1,15 @@
+#nullable disable
+using EllieBot.Voice;
+
+namespace EllieBot.Modules.Music;
+
+public interface IVoiceProxy
+{
+    VoiceProxy.VoiceProxyState State { get; }
+    public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length);
+    public void SetGateway(VoiceGateway gateway);
+    Task StartSpeakingAsync();
+    Task StopSpeakingAsync();
+    public Task StartGateway();
+    Task StopGateway();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs
new file mode 100644
index 0000000..e4c2f53
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs
@@ -0,0 +1,11 @@
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Music;
+
+public interface IYoutubeResolver : IPlatformQueryResolver
+{
+    public Task<ITrackInfo?> ResolveByIdAsync(string id);
+    IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query);
+    Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId);
+    Task<string?> GetStreamUrl(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs
new file mode 100644
index 0000000..4e663ad
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs
@@ -0,0 +1,19 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class CachableTrackData : ICachableTrackData
+{
+    public string Title { get; set; } = string.Empty;
+    public string Id { get; set; } = string.Empty;
+    public string Url { get; set; } = string.Empty;
+    public string Thumbnail { get; set; } = string.Empty;
+    public double TotalDurationMs { get; set; }
+
+    [JsonIgnore]
+    public TimeSpan Duration
+        => TimeSpan.FromMilliseconds(TotalDurationMs);
+
+    public MusicPlatform Platform { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs
new file mode 100644
index 0000000..9c8a9a3
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs
@@ -0,0 +1,95 @@
+#nullable disable
+using System.Runtime.InteropServices;
+
+namespace EllieBot.Modules.Music.Common;
+
+public sealed class MultimediaTimer : IDisposable
+{
+    private LpTimeProcDelegate lpTimeProc;
+    private readonly uint _eventId;
+    private readonly Action<object> _callback;
+    private readonly object _state;
+
+    public MultimediaTimer(Action<object> callback, object state, int period)
+    {
+        if (period <= 0)
+            throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0");
+
+        _callback = callback;
+        _state = state;
+
+        lpTimeProc = CallbackInternal;
+        _eventId = timeSetEvent((uint)period, 1, lpTimeProc, 0, TimerMode.Periodic);
+    }
+
+    /// <summary>
+    ///     The timeSetEvent function starts a specified timer event. The multimedia timer runs in its own thread.
+    ///     After the event is activated, it calls the specified callback function or sets or pulses the specified
+    ///     event object.
+    /// </summary>
+    /// <param name="uDelay">
+    ///     Event delay, in milliseconds. If this value is not in the range of the minimum and
+    ///     maximum event delays supported by the timer, the function returns an error.
+    /// </param>
+    /// <param name="uResolution">
+    ///     Resolution of the timer event, in milliseconds. The resolution increases with
+    ///     smaller values; a resolution of 0 indicates periodic events should occur with the greatest possible accuracy.
+    ///     To reduce system overhead, however, you should use the maximum value appropriate for your application.
+    /// </param>
+    /// <param name="lpTimeProc">
+    ///     Pointer to a callback function that is called once upon expiration of a single event or periodically upon
+    ///     expiration of periodic events. If fuEvent specifies the TIME_CALLBACK_EVENT_SET or TIME_CALLBACK_EVENT_PULSE
+    ///     flag, then the lpTimeProc parameter is interpreted as a handle to an event object. The event will be set or
+    ///     pulsed upon completion of a single event or periodically upon completion of periodic events.
+    ///     For any other value of fuEvent, the lpTimeProc parameter is a pointer to a callback function of type
+    ///     LPTIMECALLBACK.
+    /// </param>
+    /// <param name="dwUser">User-supplied callback data.</param>
+    /// <param name="fuEvent"></param>
+    /// <returns>Timer event type. This parameter may include one of the following values.</returns>
+    [DllImport("Winmm.dll")]
+    private static extern uint timeSetEvent(
+        uint uDelay,
+        uint uResolution,
+        LpTimeProcDelegate lpTimeProc,
+        int dwUser,
+        TimerMode fuEvent);
+
+    /// <summary>
+    ///     The timeKillEvent function cancels a specified timer event.
+    /// </summary>
+    /// <param name="uTimerId">
+    ///     Identifier of the timer event to cancel.
+    ///     This identifier was returned by the timeSetEvent function when the timer event was set up.
+    /// </param>
+    /// <returns>Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist.</returns>
+    [DllImport("Winmm.dll")]
+    private static extern int timeKillEvent(uint uTimerId);
+
+    private void CallbackInternal(
+        uint uTimerId,
+        uint uMsg,
+        int dwUser,
+        int dw1,
+        int dw2)
+        => _callback(_state);
+
+    public void Dispose()
+    {
+        lpTimeProc = default;
+        timeKillEvent(_eventId);
+    }
+
+    private delegate void LpTimeProcDelegate(
+        uint uTimerId,
+        uint uMsg,
+        int dwUser,
+        int dw1,
+        int dw2);
+
+    private enum TimerMode
+    {
+        OneShot,
+        Periodic
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs
new file mode 100644
index 0000000..cab883b
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs
@@ -0,0 +1,57 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public static class MusicExtensions
+{
+    public static string PrettyTotalTime(this IMusicPlayer mp)
+    {
+        long sum = 0;
+        foreach (var track in mp.GetQueuedTracks())
+        {
+            if (track.Duration == TimeSpan.MaxValue)
+                return "∞";
+
+            sum += track.Duration.Ticks;
+        }
+
+        var total = new TimeSpan(sum);
+
+        return total.ToString(@"hh\:mm\:ss");
+    }
+
+    public static string PrettyVolume(this IMusicPlayer mp)
+        => $"🔉 {(int)(mp.Volume * 100)}%";
+
+    public static string PrettyName(this ITrackInfo trackInfo)
+        => $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**";
+
+    public static string PrettyInfo(this IQueuedTrackInfo trackInfo)
+        => $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}";
+
+    public static string PrettyFullName(this IQueuedTrackInfo trackInfo)
+        => $@"{trackInfo.PrettyName()}
+		`{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {Format.Sanitize(trackInfo.Queuer.TrimTo(15))}`";
+
+    public static string PrettyTotalTime(this ITrackInfo trackInfo)
+    {
+        if (trackInfo.Duration == TimeSpan.Zero)
+            return "(?)";
+        if (trackInfo.Duration == TimeSpan.MaxValue)
+            return "∞";
+        if (trackInfo.Duration.TotalHours >= 1)
+            return trackInfo.Duration.ToString("""hh\:mm\:ss""");
+
+        return trackInfo.Duration.ToString("""mm\:ss""");
+    }
+
+    public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id)
+        => new CachableTrackData
+        {
+            TotalDurationMs = trackInfo.Duration.TotalMilliseconds,
+            Id = id,
+            Thumbnail = trackInfo.Thumbnail,
+            Url = trackInfo.Url,
+            Platform = trackInfo.Platform,
+            Title = trackInfo.Title
+        };
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs
new file mode 100644
index 0000000..0d6c149
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Music;
+
+public enum MusicPlatform
+{
+    Radio,
+    Youtube,
+    Local,
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs
new file mode 100644
index 0000000..02cb4e2
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs
@@ -0,0 +1,545 @@
+using EllieBot.Voice;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Music.Resolvers;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class MusicPlayer : IMusicPlayer
+{
+    public event Func<IMusicPlayer, IQueuedTrackInfo, Task>? OnCompleted;
+    public event Func<IMusicPlayer, IQueuedTrackInfo, int, Task>? OnStarted;
+    public event Func<IMusicPlayer, Task>? OnQueueStopped;
+    public bool IsKilled { get; private set; }
+    public bool IsStopped { get; private set; }
+    public bool IsPaused { get; private set; }
+    public PlayerRepeatType Repeat { get; private set; }
+
+    public int CurrentIndex
+        => _queue.Index;
+
+    public float Volume { get; private set; } = 1.0f;
+
+    private readonly AdjustVolumeDelegate _adjustVolume;
+    private readonly VoiceClient _vc;
+
+    private readonly IMusicQueue _queue;
+    private readonly ITrackResolveProvider _trackResolveProvider;
+    private readonly IYoutubeResolverFactory _ytResolverFactory;
+    private readonly IVoiceProxy _proxy;
+    private readonly IGoogleApiService _googleApiService;
+    private readonly ISongBuffer _songBuffer;
+
+    private bool skipped;
+    private int? forceIndex;
+    private readonly Thread _thread;
+    private readonly Random _rng;
+
+    public bool AutoPlay { get; set; }
+
+    public MusicPlayer(
+        IMusicQueue queue,
+        ITrackResolveProvider trackResolveProvider,
+        IYoutubeResolverFactory ytResolverFactory,
+        IVoiceProxy proxy,
+        IGoogleApiService googleApiService,
+        QualityPreset qualityPreset,
+        bool autoPlay)
+    {
+        _queue = queue;
+        _trackResolveProvider = trackResolveProvider;
+        _ytResolverFactory = ytResolverFactory;
+        _proxy = proxy;
+        _googleApiService = googleApiService;
+        AutoPlay = autoPlay;
+        _rng = new EllieRandom();
+
+        _vc = GetVoiceClient(qualityPreset);
+        if (_vc.BitDepth == 16)
+            _adjustVolume = AdjustVolumeInt16;
+        else
+            _adjustVolume = AdjustVolumeFloat32;
+
+        _songBuffer = new PoopyBufferImmortalized(_vc.InputLength);
+
+        _thread = new(async () => { await PlayLoop(); });
+        _thread.Start();
+    }
+
+    private static VoiceClient GetVoiceClient(QualityPreset qualityPreset)
+        => qualityPreset switch
+        {
+            QualityPreset.Highest => new(),
+            QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40),
+            QualityPreset.Medium => new(SampleRate._48k,
+                Bitrate._96k,
+                Channels.Two,
+                FrameDelay.Delay40,
+                BitDepthEnum.UInt16),
+            QualityPreset.Low => new(SampleRate._48k,
+                Bitrate._64k,
+                Channels.Two,
+                FrameDelay.Delay40,
+                BitDepthEnum.UInt16),
+            _ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null)
+        };
+
+    private async Task PlayLoop()
+    {
+        var sw = new Stopwatch();
+
+        while (!IsKilled)
+        {
+            // wait until a song is available in the queue
+            // or until the queue is resumed
+            var track = _queue.GetCurrent(out var index);
+
+            if (track is null || IsStopped)
+            {
+                await Task.Delay(500);
+                continue;
+            }
+
+            if (skipped)
+            {
+                skipped = false;
+                _queue.Advance();
+                continue;
+            }
+
+            using var cancellationTokenSource = new CancellationTokenSource();
+            var token = cancellationTokenSource.Token;
+            try
+            {
+                // light up green in vc
+                _ = _proxy.StartSpeakingAsync();
+
+                _ = OnStarted?.Invoke(this, track, index);
+
+                // make sure song buffer is ready to be (re)used
+                _songBuffer.Reset();
+
+                var streamUrl = await GetStreamUrl(track);
+                // start up the data source
+                using var source = FfmpegTrackDataSource.CreateAsync(
+                    _vc.BitDepth,
+                    streamUrl,
+                    track.Platform == MusicPlatform.Local);
+
+                // start moving data from the source into the buffer
+                // this method will return once the sufficient prebuffering is done
+                await _songBuffer.BufferAsync(source, token);
+
+                // // Implemenation with multimedia timer. Works but a hassle because no support for switching
+                // // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option
+                // // for selfhosters.
+                // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+                // {
+                //     var cancelSource = new CancellationTokenSource();
+                //     var cancelToken = cancelSource.Token;
+                //     using var timer = new MultimediaTimer(_ =>
+                //     {
+                //         if (IsStopped || IsKilled)
+                //         {
+                //             cancelSource.Cancel();
+                //             return;
+                //         }
+                //         
+                //         if (_skipped)
+                //         {
+                //             _skipped = false;
+                //             cancelSource.Cancel();
+                //             return;
+                //         }
+                //
+                //         if (IsPaused)
+                //             return;
+                //
+                //         try
+                //         {
+                //             // this should tolerate certain number of errors
+                //             var result = CopyChunkToOutput(_songBuffer, _vc);
+                //             if (!result)
+                //                 cancelSource.Cancel();
+                //               
+                //         }
+                //         catch (Exception ex)
+                //         {
+                //             Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
+                //             cancelSource.Cancel();
+                //         }
+                //
+                //     }, null, 20);
+                //     
+                //     while(true)
+                //         await Task.Delay(1000, cancelToken);
+                // }
+
+                // start sending data
+                var ticksPerMs = 1000f / Stopwatch.Frequency;
+                sw.Start();
+                Thread.Sleep(2);
+
+                var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3;
+
+                var errorCount = 0;
+                while (!IsStopped && !IsKilled)
+                {
+                    // doing the skip this way instead of in the condition
+                    // ensures that a song will for sure be skipped
+                    if (skipped)
+                    {
+                        skipped = false;
+                        break;
+                    }
+
+                    if (IsPaused)
+                    {
+                        await Task.Delay(200);
+                        continue;
+                    }
+
+                    sw.Restart();
+                    var ticks = sw.ElapsedTicks;
+                    try
+                    {
+                        var result = CopyChunkToOutput(_songBuffer, _vc);
+
+                        // if song is finished
+                        if (result is null)
+                            break;
+
+                        if (result is true)
+                        {
+                            if (errorCount > 0)
+                            {
+                                _ = _proxy.StartSpeakingAsync();
+                                errorCount = 0;
+                            }
+
+                            // FUTURE windows multimedia api
+
+                            // wait for slightly less than the latency
+                            Thread.Sleep(delay);
+
+                            // and then spin out the rest
+                            while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f)
+                                Thread.SpinWait(100);
+                        }
+                        else
+                        {
+                            // result is false is either when the gateway is being swapped 
+                            // or if the bot is reconnecting, or just disconnected for whatever reason
+
+                            // tolerate up to 15x200ms of failures (3 seconds)
+                            if (++errorCount <= 15)
+                            {
+                                await Task.Delay(200);
+                                continue;
+                            }
+
+                            Log.Warning("Can't send data to voice channel");
+
+                            IsStopped = true;
+                            // if errors are happening for more than 3 seconds
+                            // Stop the player
+                            break;
+                        }
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message);
+                    }
+                }
+            }
+            catch (Win32Exception)
+            {
+                IsStopped = true;
+                Log.Error("Please install ffmpeg and make sure it's added to your "
+                          + "PATH environment variable before trying again");
+                
+            }
+            catch (OperationCanceledException)
+            {
+                Log.Information("Song skipped");
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message);
+                await Task.Delay(3_000);
+            }
+            finally
+            {
+                cancellationTokenSource.Cancel();
+                // turn off green in vc
+
+                _ = OnCompleted?.Invoke(this, track);
+
+                if (AutoPlay && track.Platform == MusicPlatform.Youtube)
+                {
+                    try
+                    {
+                        var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5);
+                        var related = relatedSongs.Shuffle().FirstOrDefault();
+                        if (related is not null)
+                        {
+                            var relatedTrack =
+                                await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube);
+                            if (relatedTrack is not null)
+                                EnqueueTrack(relatedTrack, "Autoplay");
+                        }
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Failed queueing a related song via autoplay");
+                    }
+                }
+
+
+                HandleQueuePostTrack();
+                skipped = false;
+
+                _ = _proxy.StopSpeakingAsync();
+
+                await Task.Delay(100);
+            }
+        }
+    }
+
+    private async Task<string?> GetStreamUrl(IQueuedTrackInfo track)
+    {
+        if (track.TrackInfo is SimpleTrackInfo sti)
+            return sti.StreamUrl;
+       
+        return await _ytResolverFactory.GetYoutubeResolver().GetStreamUrl(track.TrackInfo.Id);
+    }
+
+    private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc)
+    {
+        var data = sb.Read(vc.InputLength, out var length);
+
+        // if nothing is read from the buffer, song is finished
+        if (data.Length == 0)
+            return null;
+
+        _adjustVolume(data, Volume);
+        return _proxy.SendPcmFrame(vc, data, length);
+    }
+
+    private void HandleQueuePostTrack()
+    {
+        if (forceIndex is { } index)
+        {
+            _queue.SetIndex(index);
+            forceIndex = null;
+            return;
+        }
+
+        var (repeat, isStopped) = (Repeat, IsStopped);
+
+        if (repeat == PlayerRepeatType.Track || isStopped)
+            return;
+
+        // if queue is being repeated, advance no matter what
+        if (repeat == PlayerRepeatType.None)
+        {
+            // if this is the last song,
+            // stop the queue
+            if (_queue.IsLast())
+            {
+                IsStopped = true;
+                OnQueueStopped?.Invoke(this);
+                return;
+            }
+
+            _queue.Advance();
+            return;
+        }
+
+        _queue.Advance();
+    }
+
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static void AdjustVolumeInt16(Span<byte> audioSamples, float volume)
+    {
+        if (Math.Abs(volume - 1f) < 0.0001f)
+            return;
+
+        var samples = MemoryMarshal.Cast<byte, short>(audioSamples);
+
+        for (var i = 0; i < samples.Length; i++)
+        {
+            ref var sample = ref samples[i];
+            sample = (short)(sample * volume);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static void AdjustVolumeFloat32(Span<byte> audioSamples, float volume)
+    {
+        if (Math.Abs(volume - 1f) < 0.0001f)
+            return;
+
+        var samples = MemoryMarshal.Cast<byte, float>(audioSamples);
+
+        for (var i = 0; i < samples.Length; i++)
+        {
+            ref var sample = ref samples[i];
+            sample *= volume;
+        }
+    }
+
+    public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync(
+        string query,
+        string queuer,
+        bool asNext,
+        MusicPlatform? forcePlatform = null)
+    {
+        var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform);
+        if (song is null)
+            return default;
+
+        int index;
+
+        if (asNext)
+            return (_queue.EnqueueNext(song, queuer, out index), index);
+
+        return (_queue.Enqueue(song, queuer, out index), index);
+    }
+
+    public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer)
+    {
+        var errorCount = 0;
+        foreach (var chunk in queries.Chunk(5))
+        {
+            if (IsKilled)
+                break;
+
+            await chunk.Select(async data =>
+                {
+                    var (query, platform) = data;
+                    try
+                    {
+                        await TryEnqueueTrackAsync(query, queuer, false, platform);
+                        errorCount = 0;
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query);
+                        ++errorCount;
+                    }
+                })
+                .WhenAll();
+
+            await Task.Delay(1000);
+
+            // > 10 errors in a row = kill
+            if (errorCount > 10)
+                break;
+        }
+    }
+
+    public void EnqueueTrack(ITrackInfo track, string queuer)
+        => _queue.Enqueue(track, queuer, out _);
+
+    public void EnqueueTracks(IEnumerable<ITrackInfo> tracks, string queuer)
+        => _queue.EnqueueMany(tracks, queuer);
+
+    public void SetRepeat(PlayerRepeatType type)
+        => Repeat = type;
+
+    public void ShuffleQueue()
+        => _queue.Shuffle(_rng);
+
+    public void Stop()
+        => IsStopped = true;
+
+    public void Clear()
+    {
+        _queue.Clear();
+        skipped = true;
+    }
+
+    public IReadOnlyCollection<IQueuedTrackInfo> GetQueuedTracks()
+        => _queue.List();
+
+    public IQueuedTrackInfo? GetCurrentTrack(out int index)
+        => _queue.GetCurrent(out index);
+
+    public void Next()
+    {
+        skipped = true;
+        IsStopped = false;
+        IsPaused = false;
+    }
+
+    public bool MoveTo(int index)
+    {
+        if (_queue.SetIndex(index))
+        {
+            forceIndex = index;
+            skipped = true;
+            IsStopped = false;
+            IsPaused = false;
+            return true;
+        }
+
+        return false;
+    }
+
+    public void SetVolume(int newVolume)
+    {
+        var normalizedVolume = newVolume / 100f;
+        if (normalizedVolume is < 0f or > 1f)
+            throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100");
+
+        Volume = normalizedVolume;
+    }
+
+    public void Kill()
+    {
+        IsKilled = true;
+        IsStopped = true;
+        IsPaused = false;
+        skipped = true;
+    }
+
+    public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo)
+    {
+        if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent))
+            return false;
+
+        if (isCurrent)
+            skipped = true;
+
+        return true;
+    }
+
+    public bool TogglePause()
+        => IsPaused = !IsPaused;
+
+    public IQueuedTrackInfo? MoveTrack(int from, int to)
+        => _queue.MoveTrack(from, to);
+
+    public void Dispose()
+    {
+        IsKilled = true;
+        OnCompleted = null;
+        OnStarted = null;
+        OnQueueStopped = null;
+        _queue.Clear();
+        _songBuffer.Dispose();
+        _vc.Dispose();
+    }
+
+    private delegate void AdjustVolumeDelegate(Span<byte> data, float volume);
+
+    public void SetFairplay()
+    {
+        _queue.ReorderFairly();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs
new file mode 100644
index 0000000..9b1c7aa
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs
@@ -0,0 +1,342 @@
+namespace EllieBot.Modules.Music;
+
+public sealed partial class MusicQueue
+{
+    private sealed class QueuedTrackInfo : IQueuedTrackInfo
+    {
+        public ITrackInfo TrackInfo { get; }
+        public string Queuer { get; }
+
+        public string Title
+            => TrackInfo.Title;
+
+        public string Url
+            => TrackInfo.Url;
+
+        public string Thumbnail
+            => TrackInfo.Thumbnail;
+
+        public TimeSpan Duration
+            => TrackInfo.Duration;
+
+        public MusicPlatform Platform
+            => TrackInfo.Platform;
+
+
+        public QueuedTrackInfo(ITrackInfo trackInfo, string queuer)
+        {
+            TrackInfo = trackInfo;
+            Queuer = queuer;
+        }
+    }
+}
+
+public sealed partial class MusicQueue : IMusicQueue
+{
+    public int Index
+    {
+        get
+        {
+            // just make sure the internal logic runs first
+            // to make sure that some potential intermediate value is not returned
+            lock (_locker)
+            {
+                return index;
+            }
+        }
+    }
+
+    public int Count
+    {
+        get
+        {
+            lock (_locker)
+            {
+                return tracks.Count;
+            }
+        }
+    }
+
+    private LinkedList<QueuedTrackInfo> tracks;
+
+    private int index;
+
+    private readonly object _locker = new();
+
+    public MusicQueue()
+    {
+        index = 0;
+        tracks = new();
+    }
+
+    public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int enqueuedAt)
+    {
+        lock (_locker)
+        {
+            var added = new QueuedTrackInfo(trackInfo, queuer);
+            enqueuedAt = tracks.Count;
+            tracks.AddLast(added);
+          
+            return added;
+        }
+    }
+
+
+    public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int trackIndex)
+    {
+        lock (_locker)
+        {
+            if (tracks.Count == 0)
+                return Enqueue(trackInfo, queuer, out trackIndex);
+
+            var currentNode = tracks.First!;
+            int i;
+            for (i = 1; i <= index; i++)
+                currentNode = currentNode.Next!; // can't be null because index is always in range of the count
+
+            var added = new QueuedTrackInfo(trackInfo, queuer);
+            trackIndex = i;
+
+            tracks.AddAfter(currentNode, added);
+
+            return added;
+        }
+    }
+
+    public void EnqueueMany(IEnumerable<ITrackInfo> toEnqueue, string queuer)
+    {
+        lock (_locker)
+        {
+            foreach (var track in toEnqueue)
+            {
+                var added = new QueuedTrackInfo(track, queuer);
+                tracks.AddLast(added);
+            }
+        }
+    }
+
+    public IReadOnlyCollection<IQueuedTrackInfo> List()
+    {
+        lock (_locker)
+        {
+            return tracks.ToList();
+        }
+    }
+
+    public IQueuedTrackInfo? GetCurrent(out int currentIndex)
+    {
+        lock (_locker)
+        {
+            currentIndex = index;
+            return tracks.ElementAtOrDefault(index);
+        }
+    }
+
+    public void Advance()
+    {
+        lock (_locker)
+        {
+            if (++index >= tracks.Count)
+                index = 0;
+        }
+    }
+
+    public void Clear()
+    {
+        lock (_locker)
+        {
+            tracks.Clear();
+        }
+    }
+
+    public bool SetIndex(int newIndex)
+    {
+        lock (_locker)
+        {
+            if (newIndex < 0 || newIndex >= tracks.Count)
+                return false;
+
+            index = newIndex;
+            return true;
+        }
+    }
+
+    private void RemoveAtInternal(int remoteAtIndex, out IQueuedTrackInfo trackInfo)
+    {
+        var removedNode = tracks.First!;
+        int i;
+        for (i = 0; i < remoteAtIndex; i++)
+            removedNode = removedNode.Next!;
+
+        trackInfo = removedNode.Value;
+        tracks.Remove(removedNode);
+
+        if (i <= index)
+            --index;
+
+        if (index < 0)
+            index = Count;
+
+        // if it was the last song in the queue
+        // // wrap back to start
+        // if (_index == Count)
+        //     _index = 0;
+        // else if (i <= _index)
+        //     if (_index == 0)
+        //         _index = Count;
+        //     else --_index;
+    }
+
+    public void RemoveCurrent()
+    {
+        lock (_locker)
+        {
+            if (index < tracks.Count)
+                RemoveAtInternal(index, out _);
+        }
+    }
+
+    public IQueuedTrackInfo? MoveTrack(int from, int to)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(from);
+        ArgumentOutOfRangeException.ThrowIfNegative(to);
+        ArgumentOutOfRangeException.ThrowIfEqual(to, from);
+
+        lock (_locker)
+        {
+            if (from >= Count || to >= Count)
+                return null;
+
+            // update current track index
+            if (from == index)
+            {
+                // if the song being moved is the current track
+                // it means that it will for sure end up on the destination
+                index = to;
+            }
+            else
+            {
+                // moving a track from below the current track means 
+                // means it will drop down
+                if (from < index)
+                    index--;
+
+                // moving a track to below the current track
+                // means it will rise up
+                if (to <= index)
+                    index++;
+
+
+                // if both from and to are below _index - net change is + 1 - 1 = 0
+                // if from is below and to is above - net change is -1 (as the track is taken and put above)
+                // if from is above and to is below - net change is 1 (as the track is inserted under)
+                // if from is above and to is above - net change is 0
+            }
+
+            // get the node which needs to be moved
+            var fromNode = tracks.First!;
+            for (var i = 0; i < from; i++)
+                fromNode = fromNode.Next!;
+
+            // remove it from the queue
+            tracks.Remove(fromNode);
+
+            // if it needs to be added as a first node,
+            // add it directly and return
+            if (to == 0)
+            {
+                tracks.AddFirst(fromNode);
+                return fromNode.Value;
+            }
+
+            // else find the node at the index before the specified target
+            var addAfterNode = tracks.First!;
+            for (var i = 1; i < to; i++)
+                addAfterNode = addAfterNode.Next!;
+
+            // and add after it
+            tracks.AddAfter(addAfterNode, fromNode);
+            return fromNode.Value;
+        }
+    }
+
+    public void Shuffle(Random rng)
+    {
+        lock (_locker)
+        {
+            var list = tracks.ToArray();
+            rng.Shuffle(list);
+            tracks = new(list);
+        }
+    }
+
+    public bool IsLast()
+    {
+        lock (_locker)
+        {
+            return index == tracks.Count // if there are no tracks
+                   || index == tracks.Count - 1;
+        }
+    }
+
+    public void ReorderFairly()
+    {
+        lock (_locker)
+        {
+            var groups = new Dictionary<string, int>();
+            var queuers = new List<Queue<QueuedTrackInfo>>();
+
+            foreach (var track in tracks.Skip(index).Concat(tracks.Take(index)))
+            {
+                if (!groups.TryGetValue(track.Queuer, out var qIndex))
+                {
+                    queuers.Add(new Queue<QueuedTrackInfo>());
+                    qIndex = queuers.Count - 1;
+                    groups.Add(track.Queuer, qIndex);
+                }
+
+                queuers[qIndex].Enqueue(track);
+            }
+
+            tracks = new LinkedList<QueuedTrackInfo>();
+            index = 0;
+
+            while (true)
+            {
+                for (var i = 0; i < queuers.Count; i++)
+                {
+                    var queue = queuers[i];
+                    tracks.AddLast(queue.Dequeue());
+
+                    if (queue.Count == 0)
+                    {
+                        queuers.RemoveAt(i);
+                        i--;
+                    }
+                }
+
+                if (queuers.Count == 0)
+                    break;
+            }
+        }
+    }
+
+    public bool TryRemoveAt(int remoteAt, out IQueuedTrackInfo? trackInfo, out bool isCurrent)
+    {
+        lock (_locker)
+        {
+            isCurrent = false;
+            trackInfo = null;
+
+            if (remoteAt < 0 || remoteAt >= tracks.Count)
+                return false;
+
+            if (remoteAt == index)
+                isCurrent = true;
+
+            RemoveAtInternal(remoteAt, out trackInfo);
+
+            return true;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs
new file mode 100644
index 0000000..b846b25
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Music;
+
+public sealed record RemoteTrackInfo(
+    string Id,
+    string Title,
+    string Url,
+    string Thumbnail,
+    TimeSpan Duration,
+    MusicPlatform Platform) : ITrackInfo;
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs
new file mode 100644
index 0000000..c7828cc
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs
@@ -0,0 +1,27 @@
+namespace EllieBot.Modules.Music;
+
+public sealed class SimpleTrackInfo : ITrackInfo
+{
+    public string Title { get; }
+    public string Url { get; }
+    public string Thumbnail { get; }
+    public TimeSpan Duration { get; }
+    public MusicPlatform Platform { get; }
+    public string? StreamUrl { get; }
+
+    public SimpleTrackInfo(
+        string title,
+        string url,
+        string thumbnail,
+        TimeSpan duration,
+        MusicPlatform platform,
+        string streamUrl)
+    {
+        Title = title;
+        Url = url;
+        Thumbnail = thumbnail;
+        Duration = duration;
+        Platform = platform;
+        StreamUrl = streamUrl;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs
new file mode 100644
index 0000000..2dcffcc
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs
@@ -0,0 +1,105 @@
+namespace EllieBot.Modules.Music;
+
+public sealed class TrackCacher : ITrackCacher
+{
+    private readonly IBotCache _cache;
+
+    public TrackCacher(IBotCache cache)
+        => _cache = cache;
+
+
+    private TypedKey<string> GetStreamLinkKey(MusicPlatform platform, string id)
+        => new($"music:stream:{platform}:{id}");
+    
+    public async Task<string?> GetOrCreateStreamLink(
+        string id,
+        MusicPlatform platform,
+        Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
+    {
+        var key = GetStreamLinkKey(platform, id);
+
+        var streamUrl = await _cache.GetOrDefaultAsync(key);
+        await _cache.RemoveAsync(key);
+        
+        if (streamUrl == default)
+        {
+            (streamUrl, _) = await streamUrlFactory();
+        }
+
+        // make a new one for later use
+        _ = Task.Run(async () =>
+        {
+            (streamUrl, var expiry) = await streamUrlFactory();
+            await CacheStreamUrlAsync(id, platform, streamUrl, expiry);
+        });
+        
+        return streamUrl;
+    }
+
+    public async Task CacheStreamUrlAsync(
+        string id,
+        MusicPlatform platform,
+        string url,
+        TimeSpan expiry)
+        => await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry);
+
+    // track data by id
+    private TypedKey<CachableTrackData> GetTrackDataKey(MusicPlatform platform, string id)
+        => new($"music:track:{platform}:{id}");
+    public async Task CacheTrackDataAsync(ICachableTrackData data)
+        => await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data));
+
+    private CachableTrackData ToCachableTrackData(ICachableTrackData data)
+        => new CachableTrackData()
+        {
+            Id = data.Id,
+            Platform = data.Platform,
+            Thumbnail = data.Thumbnail,
+            Title = data.Title,
+            Url = data.Url,
+        };
+
+    public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
+        => await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id)); 
+    
+    
+    // track data by query
+    private TypedKey<CachableTrackData> GetTrackDataQueryKey(MusicPlatform platform, string query)
+        => new($"music:track:{platform}:q:{query}");
+
+    public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
+        => await Task.WhenAll(
+            _cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), ToCachableTrackData(data)).AsTask(),
+            _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)).AsTask());
+    
+    public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
+        => await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query));
+
+
+    // playlist track ids by playlist id
+    private TypedKey<List<string>> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform)
+        => new($"music:playlist_tracks:{platform}:{playlist}");
+
+    public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
+        => await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList());
+
+    public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
+    {
+        var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform));
+        if (result.TryGetValue(out var val))
+            return val;
+
+        return Array.Empty<string>();
+    }
+
+
+    // playlist id by query
+    private TypedKey<string> GetPlaylistCacheKey(string query, MusicPlatform platform)
+        => new($"music:playlist_id:{platform}:{query}");
+    
+    public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
+        => await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId);
+
+    public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
+        => await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform));
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs
new file mode 100644
index 0000000..08bb8b8
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs
@@ -0,0 +1,102 @@
+#nullable disable
+using EllieBot.Voice;
+using EllieBot.Voice.Models;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class VoiceProxy : IVoiceProxy
+{
+    public enum VoiceProxyState
+    {
+        Created,
+        Started,
+        Stopped
+    }
+
+    private const int MAX_ERROR_COUNT = 20;
+    private const int DELAY_ON_ERROR_MILISECONDS = 200;
+
+    public VoiceProxyState State
+        => gateway switch
+        {
+            { Started: true, Stopped: false } => VoiceProxyState.Started,
+            { Stopped: false } => VoiceProxyState.Created,
+            _ => VoiceProxyState.Stopped
+        };
+
+
+    private VoiceGateway gateway;
+
+    public VoiceProxy(VoiceGateway initial)
+        => gateway = initial;
+
+    public bool SendPcmFrame(VoiceClient vc, Span<byte> data, int length)
+    {
+        try
+        {
+            var gw = gateway;
+            if (gw is null || gw.Stopped || !gw.Started)
+                return false;
+
+            vc.SendPcmFrame(gw, data, 0, length);
+            return true;
+        }
+        catch (Exception)
+        {
+            return false;
+        }
+    }
+
+    public async Task<bool> RunGatewayAction(Func<VoiceGateway, Task> action)
+    {
+        var errorCount = 0;
+        do
+        {
+            if (State == VoiceProxyState.Stopped)
+                break;
+
+            try
+            {
+                var gw = gateway;
+                if (gw is null || !gw.ConnectingFinished.Task.IsCompleted)
+                {
+                    ++errorCount;
+                    await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
+                    Log.Debug("Gateway is not ready");
+                    continue;
+                }
+
+                await action(gw);
+                errorCount = 0;
+            }
+            catch (Exception ex)
+            {
+                ++errorCount;
+                await Task.Delay(DELAY_ON_ERROR_MILISECONDS);
+                Log.Debug(ex, "Error performing proxy gateway action");
+            }
+        } while (errorCount is > 0 and <= MAX_ERROR_COUNT);
+
+        return State != VoiceProxyState.Stopped && errorCount <= MAX_ERROR_COUNT;
+    }
+
+    public void SetGateway(VoiceGateway newGateway)
+        => gateway = newGateway;
+
+    public Task StartSpeakingAsync()
+        => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.Microphone));
+
+    public Task StopSpeakingAsync()
+        => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.None));
+
+    public async Task StartGateway()
+        => await gateway.Start();
+
+    public Task StopGateway()
+    {
+        if (gateway is { } gw)
+            return gw.StopAsync();
+
+        return Task.CompletedTask;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
new file mode 100644
index 0000000..cca2727
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/InvTrackInfo.cs
@@ -0,0 +1,12 @@
+namespace EllieBot.Modules.Music;
+
+public sealed class InvTrackInfo : ITrackInfo
+{
+    public required string Id { get; init; }
+    public required string Title { get; init; }
+    public required string Url { get; init; }
+    public required string Thumbnail { get; init; }
+    public required TimeSpan Duration { get; init; }
+    public required MusicPlatform Platform { get; init; }
+    public required string? StreamUrl { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs
new file mode 100644
index 0000000..fa21693
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/InvidiousYoutubeResolver.cs
@@ -0,0 +1,108 @@
+using EllieBot.Modules.Searches;
+using System.Net.Http.Json;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class InvidiousYoutubeResolver : IYoutubeResolver
+{
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly SearchesConfigService _sc;
+    private readonly EllieRandom _rng;
+
+    private string InvidiousApiUrl
+        => _sc.Data.InvidiousInstances[_rng.Next(0, _sc.Data.InvidiousInstances.Count)];
+
+    public InvidiousYoutubeResolver(IHttpClientFactory httpFactory, SearchesConfigService sc)
+    {
+        _rng = new EllieRandom();
+        _httpFactory = httpFactory;
+        _sc = sc;
+    }
+
+    public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
+    {
+        using var http = _httpFactory.CreateClient();
+
+        var items = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
+            $"{InvidiousApiUrl}/api/v1/search"
+            + $"?q={query}"
+            + $"&type=video");
+
+        if (items is null || items.Count == 0)
+            return null;
+
+
+        var res = items.First();
+        
+        return new InvTrackInfo()
+        {
+            Id = res.VideoId,
+            Title = res.Title,
+            Url = $"https://youtube.com/watch?v={res.VideoId}",
+            Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
+            Duration = TimeSpan.FromSeconds(res.LengthSeconds),
+            Platform = MusicPlatform.Youtube,
+            StreamUrl = null,
+        };
+    }
+
+    public async Task<ITrackInfo?> ResolveByIdAsync(string id)
+        => await InternalResolveByIdAsync(id);
+    
+    private async Task<InvTrackInfo?> InternalResolveByIdAsync(string id)
+    {
+        using var http = _httpFactory.CreateClient();
+
+        var res = await http.GetFromJsonAsync<InvidiousVideoResponse>(
+            $"{InvidiousApiUrl}/api/v1/videos/{id}");
+
+        if (res is null)
+            return null;
+
+        return new InvTrackInfo()
+        {
+            Id = res.VideoId,
+            Title = res.Title,
+            Url = $"https://youtube.com/watch?v={res.VideoId}",
+            Thumbnail = res.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
+            Duration = TimeSpan.FromSeconds(res.LengthSeconds),
+            Platform = MusicPlatform.Youtube,
+            StreamUrl = res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_HIGH")?.Url
+                        ?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_MEDIUM")?.Url
+                        ?? res.AdaptiveFormats.FirstOrDefault(x => x.AudioQuality == "AUDIO_QUALITY_LOW")?.Url
+        };
+    }
+
+    public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
+    {
+        using var http = _httpFactory.CreateClient();
+        var res = await http.GetFromJsonAsync<InvidiousPlaylistResponse>(
+            $"{InvidiousApiUrl}/api/v1/search?type=video&q={query}");
+
+        if (res is null)
+            yield break;
+
+        foreach (var video in res.Videos)
+        {
+            yield return new InvTrackInfo()
+            {
+                Id = video.VideoId,
+                Title = video.Title,
+                Url = $"https://youtube.com/watch?v={video.VideoId}",
+                Thumbnail = video.Thumbnails?.Select(x => x.Url).FirstOrDefault() ?? string.Empty,
+                Duration = TimeSpan.FromSeconds(video.LengthSeconds),
+                Platform = MusicPlatform.Youtube,
+                StreamUrl = null
+            };
+        }
+    }
+
+    public Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryExtractingId)
+        => ResolveByQueryAsync(query);
+
+    public async Task<string?> GetStreamUrl(string videoId)
+    {
+        var video = await InternalResolveByIdAsync(videoId);
+        return video?.StreamUrl;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs
new file mode 100644
index 0000000..d728ce7
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs
@@ -0,0 +1,122 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Text;
+
+namespace EllieBot.Modules.Music.Resolvers;
+
+public sealed class LocalTrackResolver : ILocalTrackResolver
+{
+    private static readonly HashSet<string> _musicExtensions = new[]
+    {
+        ".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX",
+        ".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V"
+    }.ToHashSet();
+
+    public async Task<ITrackInfo?> ResolveByQueryAsync(string query)
+    {
+        if (!File.Exists(query))
+            return null;
+
+        var trackDuration = await Ffprobe.GetTrackDurationAsync(query);
+        return new SimpleTrackInfo(Path.GetFileNameWithoutExtension(query),
+            $"https://google.com?q={Uri.EscapeDataString(Path.GetFileNameWithoutExtension(query))}",
+            "https://cdn.discordapp.com/attachments/155726317222887425/261850914783100928/1482522077_music.png",
+            trackDuration,
+            MusicPlatform.Local,
+            $"\"{Path.GetFullPath(query)}\"");
+    }
+
+    public async IAsyncEnumerable<ITrackInfo> ResolveDirectoryAsync(string dirPath)
+    {
+        DirectoryInfo dir;
+        try
+        {
+            dir = new(dirPath);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath);
+            yield break;
+        }
+
+        var files = dir.EnumerateFiles()
+                       .Where(x =>
+                       {
+                           if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System)
+                               && _musicExtensions.Contains(x.Extension.ToUpperInvariant()))
+                               return true;
+                           return false;
+                       })
+                       .ToList();
+
+        var firstFile = files.FirstOrDefault()?.FullName;
+        if (firstFile is null)
+            yield break;
+
+        var firstData = await ResolveByQueryAsync(firstFile);
+        if (firstData is not null)
+            yield return firstData;
+
+        var fileChunks = files.Skip(1).Chunk(10);
+        foreach (var chunk in fileChunks)
+        {
+            var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll();
+
+            // nullable reference types being annoying
+            foreach (var p in part)
+            {
+                if (p is null)
+                    continue;
+
+                yield return p;
+            }
+        }
+    }
+}
+
+public static class Ffprobe
+{
+    public static async Task<TimeSpan> GetTrackDurationAsync(string query)
+    {
+        query = query.Replace("\"", "");
+
+        try
+        {
+            using var p = Process.Start(new ProcessStartInfo
+            {
+                FileName = "ffprobe",
+                Arguments =
+                    $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 -- \"{query}\"",
+                UseShellExecute = false,
+                RedirectStandardOutput = true,
+                RedirectStandardError = true,
+                StandardOutputEncoding = Encoding.UTF8,
+                StandardErrorEncoding = Encoding.UTF8,
+                CreateNoWindow = true
+            });
+
+            if (p is null)
+                return TimeSpan.Zero;
+
+            var data = await p.StandardOutput.ReadToEndAsync();
+            if (double.TryParse(data, out var seconds))
+                return TimeSpan.FromSeconds(seconds);
+
+            var errorData = await p.StandardError.ReadToEndAsync();
+            if (!string.IsNullOrWhiteSpace(errorData))
+                Log.Warning("Ffprobe warning for file {FileName}: {ErrorMessage}", query, errorData);
+
+            return TimeSpan.Zero;
+        }
+        catch (Win32Exception)
+        {
+            Log.Warning("Ffprobe was likely not installed. Local song durations will show as (?)");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message);
+        }
+
+        return TimeSpan.Zero;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs
new file mode 100644
index 0000000..475b026
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs
@@ -0,0 +1,106 @@
+#nullable disable
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Music.Resolvers;
+
+public class RadioResolver : IRadioResolver
+{
+    private readonly Regex _plsRegex = new(@"File1=(?<url>.*?)\n", RegexOptions.Compiled);
+    private readonly Regex _m3URegex = new(@"(?<url>^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline);
+    private readonly Regex _asxRegex = new(@"<ref href=""(?<url>.*?)""", RegexOptions.Compiled);
+    private readonly Regex _xspfRegex = new(@"<location>(?<url>.*?)</location>", RegexOptions.Compiled);
+
+    public async Task<ITrackInfo> ResolveByQueryAsync(string query)
+    {
+        if (IsRadioLink(query))
+            query = await HandleStreamContainers(query);
+
+        return new SimpleTrackInfo(query.TrimTo(50),
+            query,
+            "https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png",
+            TimeSpan.MaxValue,
+            MusicPlatform.Radio,
+            query);
+    }
+
+    public static bool IsRadioLink(string query)
+        => (query.StartsWith("http", StringComparison.InvariantCulture)
+            || query.StartsWith("ww", StringComparison.InvariantCulture))
+           && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
+
+    private async Task<string> HandleStreamContainers(string query)
+    {
+        string file = null;
+        try
+        {
+            using var http = new HttpClient();
+            file = await http.GetStringAsync(query);
+        }
+        catch
+        {
+            return query;
+        }
+
+        if (query.Contains(".pls"))
+        {
+            try
+            {
+                var m = _plsRegex.Match(file);
+                var res = m.Groups["url"]?.ToString();
+                return res?.Trim();
+            }
+            catch
+            {
+                Log.Warning("Failed reading .pls:\n{PlsFile}", file);
+                return null;
+            }
+        }
+
+        if (query.Contains(".m3u"))
+        {
+            try
+            {
+                var m = _m3URegex.Match(file);
+                var res = m.Groups["url"].ToString();
+                return res.Trim();
+            }
+            catch
+            {
+                Log.Warning("Failed reading .m3u:\n{M3uFile}", file);
+                return null;
+            }
+        }
+
+        if (query.Contains(".asx"))
+        {
+            try
+            {
+                var m = _asxRegex.Match(file);
+                var res = m.Groups["url"].ToString();
+                return res.Trim();
+            }
+            catch
+            {
+                Log.Warning("Failed reading .asx:\n{AsxFile}", file);
+                return null;
+            }
+        }
+
+        if (query.Contains(".xspf"))
+        {
+            try
+            {
+                var m = _xspfRegex.Match(file);
+                var res = m.Groups["url"].ToString();
+                return res.Trim();
+            }
+            catch
+            {
+                Log.Warning("Failed reading .xspf:\n{XspfFile}", file);
+                return null;
+            }
+        }
+
+        return query;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs
new file mode 100644
index 0000000..21a6adf
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs
@@ -0,0 +1,54 @@
+using EllieBot.Modules.Music.Resolvers;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class TrackResolveProvider : ITrackResolveProvider
+{
+    private readonly IYoutubeResolverFactory _ytResolver;
+    private readonly ILocalTrackResolver _localResolver;
+    private readonly IRadioResolver _radioResolver;
+
+    public TrackResolveProvider(
+        IYoutubeResolverFactory ytResolver,
+        ILocalTrackResolver localResolver,
+        IRadioResolver radioResolver)
+    {
+        _ytResolver = ytResolver;
+        _localResolver = localResolver;
+        _radioResolver = radioResolver;
+    }
+
+    public Task<ITrackInfo?> QuerySongAsync(string query, MusicPlatform? forcePlatform)
+    {
+        switch (forcePlatform)
+        {
+            case MusicPlatform.Radio:
+                return _radioResolver.ResolveByQueryAsync(query);
+            case MusicPlatform.Youtube:
+                return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query);
+            case MusicPlatform.Local:
+                return _localResolver.ResolveByQueryAsync(query);
+            case null:
+                var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
+
+                if (match.Success)
+                    return _ytResolver.GetYoutubeResolver().ResolveByIdAsync(match.Groups["id"].Value);
+
+                if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile)
+                    return _localResolver.ResolveByQueryAsync(uri.AbsolutePath);
+
+                if (IsRadioLink(query))
+                    return _radioResolver.ResolveByQueryAsync(query);
+
+                return _ytResolver.GetYoutubeResolver().ResolveByQueryAsync(query, false);
+            default:
+                Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform);
+                return Task.FromResult<ITrackInfo?>(null);
+        }
+    }
+
+    public static bool IsRadioLink(string query)
+        => (query.StartsWith("http", StringComparison.InvariantCulture)
+            || query.StartsWith("ww", StringComparison.InvariantCulture))
+           && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf"));
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs
new file mode 100644
index 0000000..869102d
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeHelpers.cs
@@ -0,0 +1,10 @@
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class YoutubeHelpers
+{
+    public static Regex YtVideoIdRegex { get; } =
+        new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})",
+            RegexOptions.Compiled);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs
new file mode 100644
index 0000000..955faca
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/YoutubeResolverFactory.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieBot.Modules.Searches;
+using EllieBot.Modules.Searches.Services;
+
+namespace EllieBot.Modules.Music.Resolvers;
+
+public interface IYoutubeResolverFactory
+{
+    IYoutubeResolver GetYoutubeResolver();
+}
+
+public sealed class YoutubeResolverFactory : IYoutubeResolverFactory
+{
+    private readonly SearchesConfigService _ss;
+    private readonly IServiceProvider _services;
+
+    public YoutubeResolverFactory(SearchesConfigService ss, IServiceProvider services)
+    {
+        _ss = ss;
+        _services = services;
+    }
+
+    public IYoutubeResolver GetYoutubeResolver()
+    {
+        var conf = _ss.Data;
+        if (conf.YtProvider == YoutubeSearcher.Invidious)
+        {
+            return _services.GetRequiredService<InvidiousYoutubeResolver>();
+        }
+
+        return _services.GetRequiredService<YtdlYoutubeResolver>();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs
new file mode 100644
index 0000000..eeb3a1c
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs
@@ -0,0 +1,316 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using EllieBot.Modules.Searches;
+
+namespace EllieBot.Modules.Music;
+
+public sealed class YtdlYoutubeResolver : IYoutubeResolver
+{
+    private static readonly string[] _durationFormats =
+    [
+        "ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss"
+    ];
+
+    private static readonly Regex _expiryRegex = new(@"(?:[\?\&]expire\=(?<timestamp>\d+))");
+
+
+    private static readonly Regex _simplePlaylistRegex = new(@"&list=(?<id>[\w\-]{12,})", RegexOptions.Compiled);
+
+
+    private readonly ITrackCacher _trackCacher;
+
+    private readonly YtdlOperation _ytdlPlaylistOperation;
+    private readonly YtdlOperation _ytdlIdOperation;
+    private readonly YtdlOperation _ytdlSearchOperation;
+
+    private readonly IGoogleApiService _google;
+
+    public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google, SearchesConfigService scs)
+    {
+        _trackCacher = trackCacher;
+        _google = google;
+
+
+        _ytdlPlaylistOperation = new("-4 "
+                                     + "--geo-bypass "
+                                     + "--encoding UTF8 "
+                                     + "-f bestaudio "
+                                     + "-e "
+                                     + "--get-url "
+                                     + "--get-id "
+                                     + "--get-thumbnail "
+                                     + "--get-duration "
+                                     + "--no-check-certificate "
+                                     + "-i "
+                                     + "--yes-playlist "
+                                     + "-- \"{0}\"",
+            scs.Data.YtProvider != YoutubeSearcher.Ytdl);
+
+        _ytdlIdOperation = new("-4 "
+                               + "--geo-bypass "
+                               + "--encoding UTF8 "
+                               + "-f bestaudio "
+                               + "-e "
+                               + "--get-url "
+                               + "--get-id "
+                               + "--get-thumbnail "
+                               + "--get-duration "
+                               + "--no-check-certificate "
+                               + "-- \"{0}\"",
+            scs.Data.YtProvider != YoutubeSearcher.Ytdl);
+
+        _ytdlSearchOperation = new("-4 "
+                                   + "--geo-bypass "
+                                   + "--encoding UTF8 "
+                                   + "-f bestaudio "
+                                   + "-e "
+                                   + "--get-url "
+                                   + "--get-id "
+                                   + "--get-thumbnail "
+                                   + "--get-duration "
+                                   + "--no-check-certificate "
+                                   + "--default-search "
+                                   + "\"ytsearch:\" -- \"{0}\"",
+            scs.Data.YtProvider != YoutubeSearcher.Ytdl);
+    }
+
+    private YtTrackData ResolveYtdlData(string ytdlOutputString)
+    {
+        if (string.IsNullOrWhiteSpace(ytdlOutputString))
+            return default;
+
+        var dataArray = ytdlOutputString.Trim().Split('\n');
+
+        if (dataArray.Length < 5)
+        {
+            Log.Information("Not enough data received: {YtdlData}", ytdlOutputString);
+            return default;
+        }
+
+        if (!TimeSpan.TryParseExact(dataArray[4], _durationFormats, CultureInfo.InvariantCulture, out var time))
+            time = TimeSpan.Zero;
+
+        var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty;
+
+        return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time);
+    }
+
+    private ITrackInfo DataToInfo(in YtTrackData trackData)
+        => new RemoteTrackInfo(
+            trackData.Id,
+            trackData.Title,
+            $"https://youtube.com/watch?v={trackData.Id}",
+            trackData.Thumbnail,
+            trackData.Duration,
+            MusicPlatform.Youtube);
+
+    private Func<Task<string?>> CreateCacherFactory(string id)
+        => () => _trackCacher.GetOrCreateStreamLink(id,
+            MusicPlatform.Youtube,
+            async () => await ExtractNewStreamUrlAsync(id));
+
+    private static TimeSpan GetExpiry(string streamUrl)
+    {
+        var match = _expiryRegex.Match(streamUrl);
+        if (match.Success && double.TryParse(match.Groups["timestamp"].ToString(), out var timestamp))
+        {
+            var realExpiry = timestamp.ToUnixTimestamp() - DateTime.UtcNow;
+            if (realExpiry > TimeSpan.FromMinutes(60))
+                return realExpiry.Subtract(TimeSpan.FromMinutes(30));
+
+            return realExpiry;
+        }
+
+        return TimeSpan.FromHours(1);
+    }
+
+    private async Task<(string StreamUrl, TimeSpan Expiry)> ExtractNewStreamUrlAsync(string id)
+    {
+        var data = await _ytdlIdOperation.GetDataAsync(id);
+        var trackInfo = ResolveYtdlData(data);
+        if (string.IsNullOrWhiteSpace(trackInfo.StreamUrl))
+            return default;
+
+        return (trackInfo.StreamUrl!, GetExpiry(trackInfo.StreamUrl!));
+    }
+
+    public async Task<ITrackInfo?> ResolveByIdAsync(string id)
+    {
+        id = id.Trim();
+
+        var cachedData = await _trackCacher.GetCachedDataByIdAsync(id, MusicPlatform.Youtube);
+        if (cachedData is null)
+        {
+            Log.Information("Resolving youtube track by Id: {YoutubeId}", id);
+
+            var data = await _ytdlIdOperation.GetDataAsync(id);
+
+            var trackInfo = ResolveYtdlData(data);
+            if (string.IsNullOrWhiteSpace(trackInfo.Title))
+                return default;
+
+            var toReturn = DataToInfo(in trackInfo);
+
+            await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)),
+                CacheStreamUrlAsync(trackInfo));
+
+            return toReturn;
+        }
+
+        return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
+    }
+
+    private Task CacheStreamUrlAsync(YtTrackData trackInfo)
+        => _trackCacher.CacheStreamUrlAsync(trackInfo.Id,
+            MusicPlatform.Youtube,
+            trackInfo.StreamUrl!,
+            GetExpiry(trackInfo.StreamUrl!));
+
+    public async IAsyncEnumerable<ITrackInfo> ResolveTracksByPlaylistIdAsync(string playlistId)
+    {
+        Log.Information("Resolving youtube tracks from playlist: {PlaylistId}", playlistId);
+        var count = 0;
+
+        var ids = await _trackCacher.GetPlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube);
+        if (ids.Count > 0)
+        {
+            foreach (var id in ids)
+            {
+                var trackInfo = await ResolveByIdAsync(id);
+                if (trackInfo is null)
+                    continue;
+
+                yield return trackInfo;
+            }
+
+            yield break;
+        }
+
+        var data = string.Empty;
+        var trackIds = new List<string>();
+        await foreach (var line in _ytdlPlaylistOperation.EnumerateDataAsync(playlistId))
+        {
+            data += line;
+
+            if (++count == 5)
+            {
+                var trackData = ResolveYtdlData(data);
+                data = string.Empty;
+                count = 0;
+                if (string.IsNullOrWhiteSpace(trackData.Id))
+                    continue;
+
+                var info = DataToInfo(in trackData);
+                await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)),
+                    CacheStreamUrlAsync(trackData));
+
+                trackIds.Add(trackData.Id);
+                yield return info;
+            }
+            else
+                data += Environment.NewLine;
+        }
+
+        await _trackCacher.CachePlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube, trackIds);
+    }
+
+    public async IAsyncEnumerable<ITrackInfo> ResolveTracksFromPlaylistAsync(string query)
+    {
+        string? playlistId;
+        // try to match playlist id inside the query, if a playlist url has been queried
+        var match = _simplePlaylistRegex.Match(query);
+        if (match.Success)
+        {
+            // if it's a success, just return from that playlist using the id
+            playlistId = match.Groups["id"].ToString();
+            await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
+                yield return track;
+
+            yield break;
+        }
+
+        // if a query is a search term, try the cache
+        playlistId = await _trackCacher.GetPlaylistIdByQueryAsync(query, MusicPlatform.Youtube);
+        if (playlistId is null)
+        {
+            // if it's not in the cache
+            // find playlist id by keyword using google api
+            try
+            {
+                var playlistIds = await _google.GetPlaylistIdsByKeywordsAsync(query);
+                playlistId = playlistIds.FirstOrDefault();
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Error Getting playlist id via GoogleApi");
+            }
+
+            // if query is not a playlist url
+            // and query result is not in the cache
+            // and api returns no values
+            // it means invalid input has been used,
+            // or google api key is not provided
+            if (playlistId is null)
+                yield break;
+        }
+
+        // cache the query -> playlist id for fast future lookup
+        await _trackCacher.CachePlaylistIdByQueryAsync(query, MusicPlatform.Youtube, playlistId);
+        await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId))
+            yield return track;
+    }
+
+    public Task<ITrackInfo?> ResolveByQueryAsync(string query)
+        => ResolveByQueryAsync(query, true);
+
+    public async Task<ITrackInfo?> ResolveByQueryAsync(string query, bool tryResolving)
+    {
+        if (tryResolving)
+        {
+            var match = YoutubeHelpers.YtVideoIdRegex.Match(query);
+            if (match.Success)
+                return await ResolveByIdAsync(match.Groups["id"].Value);
+        }
+
+        Log.Information("Resolving youtube song by search term: {YoutubeQuery}", query);
+
+        var cachedData = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.Youtube);
+        if (cachedData is null || string.IsNullOrWhiteSpace(cachedData.Title))
+        {
+            var stringData = await _ytdlSearchOperation.GetDataAsync(query);
+            var trackData = ResolveYtdlData(stringData);
+
+            var trackInfo = DataToInfo(trackData);
+            await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)),
+                CacheStreamUrlAsync(trackData));
+            return trackInfo;
+        }
+
+        return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration));
+    }
+
+    public Task<string?> GetStreamUrl(string videoId)
+        => CreateCacherFactory(videoId)();
+    private readonly struct YtTrackData
+    {
+        public readonly string Title;
+        public readonly string Id;
+        public readonly string Thumbnail;
+        public readonly string? StreamUrl;
+        public readonly TimeSpan Duration;
+
+        public YtTrackData(
+            string title,
+            string id,
+            string thumbnail,
+            string? streamUrl,
+            TimeSpan duration)
+        {
+            Title = title.Trim();
+            Id = id.Trim();
+            Thumbnail = thumbnail;
+            StreamUrl = streamUrl;
+            Duration = duration;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs
new file mode 100644
index 0000000..d8f81b5
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs
@@ -0,0 +1,27 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Db;
+
+public static class MusicPlayerSettingsExtensions
+{
+    public static async Task<MusicPlayerSettings> ForGuildAsync(this DbSet<MusicPlayerSettings> settings, ulong guildId)
+    {
+        var toReturn = await settings.AsQueryable().FirstOrDefaultAsync(x => x.GuildId == guildId);
+
+        if (toReturn is null)
+        {
+            var newSettings = new MusicPlayerSettings
+            {
+                GuildId = guildId,
+                PlayerRepeat = PlayerRepeatType.Queue
+            };
+
+            await settings.AddAsync(newSettings);
+            return newSettings;
+        }
+
+        return toReturn;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs
new file mode 100644
index 0000000..697e35e
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class MusicPlaylist : DbEntity
+{
+    public string Name { get; set; }
+    public string Author { get; set; }
+    public ulong AuthorId { get; set; }
+    public List<PlaylistSong> Songs { get; set; } = new();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs
new file mode 100644
index 0000000..0e3e603
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs
@@ -0,0 +1,18 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Db;
+
+public static class MusicPlaylistExtensions
+{
+    public static List<MusicPlaylist> GetPlaylistsOnPage(this DbSet<MusicPlaylist> playlists, int num)
+    {
+        ArgumentOutOfRangeException.ThrowIfLessThan(num, 1);
+
+        return playlists.AsQueryable().Skip((num - 1) * 20).Take(20).Include(pl => pl.Songs).ToList();
+    }
+
+    public static MusicPlaylist GetWithSongs(this DbSet<MusicPlaylist> playlists, int id)
+        => playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs
new file mode 100644
index 0000000..40f8397
--- /dev/null
+++ b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs
@@ -0,0 +1,61 @@
+#nullable disable
+namespace EllieBot.Db.Models;
+
+public class MusicPlayerSettings
+{
+    /// <summary>
+    ///     Auto generated Id
+    /// </summary>
+    public int Id { get; set; }
+
+    /// <summary>
+    ///     Id of the guild
+    /// </summary>
+    public ulong GuildId { get; set; }
+
+    /// <summary>
+    ///     Queue repeat type
+    /// </summary>
+    public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue;
+
+    /// <summary>
+    ///     Channel id the bot will always try to send track related messages to
+    /// </summary>
+    public ulong? MusicChannelId { get; set; }
+
+    /// <summary>
+    ///     Default volume player will be created with
+    /// </summary>
+    public int Volume { get; set; } = 100;
+
+    /// <summary>
+    ///     Whether the bot should auto disconnect from the voice channel once the queue is done
+    ///     This only has effect if
+    /// </summary>
+    public bool AutoDisconnect { get; set; }
+
+    /// <summary>
+    ///     Selected quality preset for the music player
+    /// </summary>
+    public QualityPreset QualityPreset { get; set; }
+
+    /// <summary>
+    ///     Whether the bot will automatically queue related songs
+    /// </summary>
+    public bool AutoPlay { get; set; }
+}
+
+public enum QualityPreset
+{
+    Highest,
+    High,
+    Medium,
+    Low
+}
+
+public enum PlayerRepeatType
+{
+    None,
+    Track,
+    Queue
+}
\ No newline at end of file