diff --git a/src/EllieBot/Modules/Music/Music.cs b/src/EllieBot/Modules/Music/Music.cs index cba818b..e5e9814 100644 --- a/src/EllieBot/Modules/Music/Music.cs +++ b/src/EllieBot/Modules/Music/Music.cs @@ -7,13 +7,24 @@ namespace EllieBot.Modules.Music; [NoPublicBot] public sealed partial class Music : EllieModule<IMusicService> { - public enum All { All = -1 } + 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 + 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"; @@ -22,9 +33,13 @@ public sealed partial class Music : EllieModule<IMusicService> private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1); private readonly ILogCommandService _logService; + private readonly ILyricsService _lyricsService; - public Music(ILogCommandService logService) - => _logService = logService; + public Music(ILogCommandService logService, ILyricsService lyricsService) + { + _logService = logService; + _lyricsService = lyricsService; + } private async Task<bool> ValidateAsync() { @@ -110,10 +125,10 @@ public sealed partial class Music : EllieModule<IMusicService> try { var embed = CreateEmbed() - .WithOkColor() - .WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL) - .WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ") - .WithFooter(trackInfo.Platform.ToString()); + .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); @@ -301,39 +316,39 @@ public sealed partial class Music : EllieModule<IMusicService> desc += tracks - .Select((v, index) => - { - index += LQ_ITEMS_PER_PAGE * curPage; - if (index == currentIndex) - return $"**⇒**`{index + 1}.` {v.PrettyFullName()}"; + .Select((v, index) => + { + index += LQ_ITEMS_PER_PAGE * curPage; + if (index == currentIndex) + return $"**⇒**`{index + 1}.` {v.PrettyFullName()}"; - return $"`{index + 1}.` {v.PrettyFullName()}"; - }) - .Join('\n'); + return $"`{index + 1}.` {v.PrettyFullName()}"; + }) + .Join('\n'); if (!string.IsNullOrWhiteSpace(add)) desc = add + "\n" + desc; var embed = 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(); + .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(); + .Paginated() + .Items(tracks) + .PageSize(LQ_ITEMS_PER_PAGE) + .CurrentPage(page) + .AddFooter(false) + .Page(PrintAction) + .SendAsync(); } // search @@ -353,15 +368,15 @@ public sealed partial class Music : EllieModule<IMusicService> var embeds = videos.Select((x, i) => CreateEmbed() - .WithOkColor() - .WithThumbnailUrl(x.Thumbnail) - .WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}")) - .ToList(); + .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(); + .Text(strs.queue_search_results) + .Embeds(embeds) + .SendAsync(); try { @@ -425,10 +440,10 @@ public sealed partial class Music : EllieModule<IMusicService> } var embed = CreateEmbed() - .WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL) - .WithDescription(track.PrettyName()) - .WithFooter(track.PrettyInfo()) - .WithErrorColor(); + .WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL) + .WithDescription(track.PrettyName()) + .WithFooter(track.PrettyInfo()) + .WithErrorColor(); await _service.SendToOutputAsync(ctx.Guild.Id, embed); } @@ -593,11 +608,11 @@ public sealed partial class Music : EllieModule<IMusicService> } var embed = 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(); + .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); @@ -652,12 +667,12 @@ public sealed partial class Music : EllieModule<IMusicService> return; var embed = 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}"); + .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(); } @@ -768,4 +783,71 @@ public sealed partial class Music : EllieModule<IMusicService> await Response().Confirm(strs.wrongsong_success(removed.Title.TrimTo(30))).SendAsync(); } } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Lyrics([Leftover] string name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + if (_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) + && mp.GetCurrentTrack(out _) is { } currentTrack) + { + name = currentTrack.Title; + } + else + { + return; + } + } + + var tracks = await _lyricsService.SearchTracksAsync(name); + + if (tracks.Count == 0) + { + await Response().Error(strs.no_lyrics_found).SendAsync(); + return; + } + + var embed = CreateEmbed() + .WithFooter("type 1-5 to select"); + + for (var i = 0; i <= 5 && i < tracks.Count; i++) + { + var item = tracks[i]; + embed.AddField($"`{(i + 1)}`. {item.Author}", item.Title, false); + } + + await Response() + .Embed(embed) + .SendAsync(); + + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _)); + + if (input is null) + return; + + var index = int.Parse(input) - 1; + if (index < 0 || index > 4) + { + return; + } + + var track = tracks[index]; + var lyrics = await _lyricsService.GetLyricsAsync(track.Id); + + if (string.IsNullOrWhiteSpace(lyrics)) + { + await Response().Error(strs.no_lyrics_found).SendAsync(); + return; + } + + await Response() + .Embed(CreateEmbed() + .WithOkColor() + .WithAuthor(track.Author) + .WithTitle(track.Title) + .WithDescription(lyrics)) + .SendAsync(); + } } \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/ILyricsService.cs b/src/EllieBot/Modules/Music/Services/ILyricsService.cs new file mode 100644 index 0000000..3506e1f --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/ILyricsService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Music; + +public interface ILyricsService +{ + public Task<IReadOnlyList<TracksItem>> SearchTracksAsync(string name); + public Task<string> GetLyricsAsync(int trackId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/LyricsService.cs b/src/EllieBot/Modules/Music/Services/LyricsService.cs new file mode 100644 index 0000000..bb67eca --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/LyricsService.cs @@ -0,0 +1,25 @@ +using Musix; + +namespace EllieBot.Modules.Music; + +public sealed class LyricsService(HttpClient client) : ILyricsService, IEService +{ + private readonly MusixMatchAPI _api = new(client); + + private static string NormalizeName(string name) + => string.Join("-", name.Split() + .Select(x => new string(x.Where(c => char.IsLetterOrDigit(c)).ToArray()))) + .Trim('-'); + + public async Task<IReadOnlyList<TracksItem>> SearchTracksAsync(string name) + => await _api.SearchTracksAsync(NormalizeName(name)) + .Fmap(x => x + .Message + .Body + .TrackList + .Map(x => new TracksItem(x.Track.ArtistName, x.Track.TrackName, x.Track.TrackId))); + + public async Task<string> GetLyricsAsync(int trackId) + => await _api.GetTrackLyricsAsync(trackId) + .Fmap(x => x.Message.Body.Lyrics.LyricsBody); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/Header.cs b/src/EllieBot/Modules/Music/_common/Musix/Header.cs new file mode 100644 index 0000000..402e00f --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/Header.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class Header +{ + [JsonPropertyName("status_code")] + public int StatusCode { get; set; } + + [JsonPropertyName("execute_time")] + public double ExecuteTime { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/Lyrics.cs b/src/EllieBot/Modules/Music/_common/Musix/Lyrics.cs new file mode 100644 index 0000000..bc78ba0 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/Lyrics.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class Lyrics +{ + [JsonPropertyName("lyrics_body")] + public string LyricsBody { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/LyricsResponse.cs b/src/EllieBot/Modules/Music/_common/Musix/LyricsResponse.cs new file mode 100644 index 0000000..e5b32fc --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/LyricsResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class LyricsResponse +{ + [JsonPropertyName("lyrics")] + public Lyrics Lyrics { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/Message.cs b/src/EllieBot/Modules/Music/_common/Musix/Message.cs new file mode 100644 index 0000000..85f276f --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/Message.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class Message<T> +{ + [JsonPropertyName("header")] + public Header Header { get; set; } = null!; + + [JsonPropertyName("body")] + public T Body { get; set; } = default!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/MusixMatchAPI.cs b/src/EllieBot/Modules/Music/_common/Musix/MusixMatchAPI.cs new file mode 100644 index 0000000..cd4279f --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/MusixMatchAPI.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Web; +using Microsoft.Extensions.Caching.Memory; +using Musix.Models; + +// All credit goes to https://github.com/Strvm/musicxmatch-api for the original implementation +namespace Musix +{ + public sealed class MusixMatchAPI + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl = "https://www.musixmatch.com/ws/1.1/"; + + private readonly string _userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"; + + private readonly IMemoryCache _cache; + private readonly JsonSerializerOptions _jsonOptions; + + public MusixMatchAPI(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent); + _httpClient.DefaultRequestHeaders.Add("Cookie", "mxm_bab=AB"); + + _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + _cache = new MemoryCache(new MemoryCacheOptions { }); + } + + private async Task<string> GetLatestAppUrlAsync() + { + var url = "https://www.musixmatch.com/search"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + request.Headers.Add("Cookie", "mxm_bab=AB"); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var htmlContent = await response.Content.ReadAsStringAsync(); + + var pattern = @"src=""([^""]*/_next/static/chunks/pages/_app-[^""]+\.js)"""; + var matches = Regex.Matches(htmlContent, pattern); + + return matches.Count > 0 + ? matches[^1].Groups[1].Value + : throw new("_app URL not found in the HTML content."); + } + + private async Task<string> GetSecret() + { + var latestAppUrl = await GetLatestAppUrlAsync(); + var response = await _httpClient.GetAsync(latestAppUrl); + response.EnsureSuccessStatusCode(); + var javascriptCode = await response.Content.ReadAsStringAsync(); + + var pattern = @"from\(\s*""(.*?)""\s*\.split"; + var match = Regex.Match(javascriptCode, pattern); + + if (match.Success) + { + var encodedString = match.Groups[1].Value; + var reversedString = new string(encodedString.Reverse().ToArray()); + var decodedBytes = Convert.FromBase64String(reversedString); + return Encoding.UTF8.GetString(decodedBytes); + } + + throw new Exception("Encoded string not found in the JavaScript code."); + } + + // It seems this is required in order to have multiword queries. + // Spaces don't work in the original implementation either + private string UrlEncode(string value) + => HttpUtility.UrlEncode(value) + .Replace("+", "-"); + + private async Task<string> GenerateSignature(string url) + { + var currentDate = DateTime.Now; + var l = currentDate.Year.ToString(); + var s = currentDate.Month.ToString("D2"); + var r = currentDate.Day.ToString("D2"); + + var message = (url + l + s + r); + var secret = await _cache.GetOrCreateAsync("secret", async _ => await GetSecret()); + var key = Encoding.UTF8.GetBytes(secret ?? string.Empty); + var messageBytes = Encoding.UTF8.GetBytes(message); + + using var hmac = new HMACSHA256(key); + var hashBytes = hmac.ComputeHash(messageBytes); + var signature = Convert.ToBase64String(hashBytes); + return $"&signature={UrlEncode(signature)}&signature_protocol=sha256"; + } + + public async Task<MusixMatchResponse<TrackSearchResponse>> SearchTracksAsync(string trackQuery, int page = 1) + { + var endpoint = + $"track.search?app_id=community-app-v1.0&format=json&q={UrlEncode(trackQuery)}&f_has_lyrics=true&page_size=100&page={page}"; + var jsonResponse = await MakeRequestAsync(endpoint); + return JsonSerializer.Deserialize<MusixMatchResponse<TrackSearchResponse>>(jsonResponse, _jsonOptions) + ?? throw new JsonException("Failed to deserialize track search response"); + } + + public async Task<MusixMatchResponse<LyricsResponse>> GetTrackLyricsAsync(int trackId) + { + var endpoint = $"track.lyrics.get?app_id=community-app-v1.0&format=json&track_id={trackId}"; + var jsonResponse = await MakeRequestAsync(endpoint); + return JsonSerializer.Deserialize<MusixMatchResponse<LyricsResponse>>(jsonResponse, _jsonOptions) + ?? throw new JsonException("Failed to deserialize lyrics response"); + } + + private async Task<string> MakeRequestAsync(string endpoint) + { + var fullUrl = _baseUrl + endpoint; + var signedUrl = fullUrl + await GenerateSignature(fullUrl); + + Console.WriteLine($"DEBUG - Request URL: {signedUrl}"); + + var request = new HttpRequestMessage(HttpMethod.Get, signedUrl); + request.Headers.UserAgent.ParseAdd(_userAgent); + request.Headers.Add("Cookie", "mxm_bab=AB"); + + var response = await _httpClient.SendAsync(request); + + var content = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"ERROR - Status: {response.StatusCode}, Content: {content}"); + response.EnsureSuccessStatusCode(); // This will throw with the appropriate status code + } + + return content; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/MusixMatchResponse.cs b/src/EllieBot/Modules/Music/_common/Musix/MusixMatchResponse.cs new file mode 100644 index 0000000..e54bdb0 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/MusixMatchResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Musix.Models +{ + public class MusixMatchResponse<T> + { + [JsonPropertyName("message")] + public Message<T> Message { get; set; } = null!; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/Track.cs b/src/EllieBot/Modules/Music/_common/Musix/Track.cs new file mode 100644 index 0000000..33fbc7e --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/Track.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class Track +{ + [JsonPropertyName("track_id")] + public int TrackId { get; set; } + + [JsonPropertyName("track_name")] + public string TrackName { get; set; } = string.Empty; + + [JsonPropertyName("artist_name")] + public string ArtistName { get; set; } = string.Empty; + + [JsonPropertyName("album_name")] + public string AlbumName { get; set; } = string.Empty; + + [JsonPropertyName("track_share_url")] + public string TrackShareUrl { get; set; } = string.Empty; + + public override string ToString() => $"{TrackName} by {ArtistName} (Album: {AlbumName})"; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/TrackListItem.cs b/src/EllieBot/Modules/Music/_common/Musix/TrackListItem.cs new file mode 100644 index 0000000..297fd97 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/TrackListItem.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class TrackListItem +{ + [JsonPropertyName("track")] + public Track Track { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Musix/TrackSearchResponse.cs b/src/EllieBot/Modules/Music/_common/Musix/TrackSearchResponse.cs new file mode 100644 index 0000000..630f9fe --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Musix/TrackSearchResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Musix.Models; + +public class TrackSearchResponse +{ + [JsonPropertyName("track_list")] + public List<TrackListItem> TrackList { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/TracksItem.cs b/src/EllieBot/Modules/Music/_common/TracksItem.cs new file mode 100644 index 0000000..3ebb77b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/TracksItem.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Music; + +public record struct TracksItem(string Author, string Title, int Id); \ No newline at end of file diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml index 38df275..8f2929a 100644 --- a/src/EllieBot/strings/aliases.yml +++ b/src/EllieBot/strings/aliases.yml @@ -1586,4 +1586,6 @@ fishspot: xprate: - xprate xpratereset: - - xpratereset \ No newline at end of file + - xpratereset +lyrics: + - lyrics \ No newline at end of file diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml index 3f9c668..e71adcf 100644 --- a/src/EllieBot/strings/commands/commands.en-US.yml +++ b/src/EllieBot/strings/commands/commands.en-US.yml @@ -4977,4 +4977,12 @@ xpratereset: params: - { } - channel: - desc: "The channel to reset the rate for." \ No newline at end of file + desc: "The channel to reset the rate for." +lyrics: + desc: |- + Looks up lyrics for a song. Very hit or miss. + ex: + - 'biri biri' + params: + - song: + desc: "The song to look up lyrics for." \ No newline at end of file diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json index 886c8eb..cd15917 100644 --- a/src/EllieBot/strings/responses/responses.en-US.json +++ b/src/EllieBot/strings/responses/responses.en-US.json @@ -1178,5 +1178,6 @@ "xp_rate_channel_set": "Channel **{0}** xp rate set to **{1}** xp per every **{2}** min.", "xp_rate_server_reset": "Server xp rate has been reset to global defaults.", "xp_rate_channel_reset": "Channel {0} xp rate has been reset.", - "xp_rate_no_gain": "No xp gain" + "xp_rate_no_gain": "No xp gain", + "no_lyrics_found": "No lyrics found." }