added .lyrics command - not very good

This commit is contained in:
Toastie 2025-03-09 17:44:06 +13:00
parent ad472fd52e
commit 2efa3c5347
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
16 changed files with 420 additions and 57 deletions

View file

@ -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();
}
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Music;
public interface ILyricsService
{
public Task<IReadOnlyList<TracksItem>> SearchTracksAsync(string name);
public Task<string> GetLyricsAsync(int trackId);
}

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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;
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class LyricsResponse
{
[JsonPropertyName("lyrics")]
public Lyrics Lyrics { get; set; } = null!;
}

View file

@ -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!;
}

View file

@ -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;
}
}
}

View file

@ -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!;
}
}

View file

@ -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})";
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Musix.Models;
public class TrackListItem
{
[JsonPropertyName("track")]
public Track Track { get; set; } = null!;
}

View file

@ -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();
}

View file

@ -0,0 +1,3 @@
namespace EllieBot.Modules.Music;
public record struct TracksItem(string Author, string Title, int Id);

View file

@ -1586,4 +1586,6 @@ fishspot:
xprate:
- xprate
xpratereset:
- xpratereset
- xpratereset
lyrics:
- lyrics

View file

@ -4977,4 +4977,12 @@ xpratereset:
params:
- { }
- channel:
desc: "The channel to reset the rate for."
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."

View file

@ -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."
}