Skip to content

Commit f55d01d

Browse files
committed
Added client to search and fetch track lyrics
1 parent 21198c3 commit f55d01d

4 files changed

Lines changed: 192 additions & 0 deletions

File tree

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.vs/
2+
packages/
3+
4+
*.suo
5+
*.user
6+
_ReSharper.*
7+
8+
9+
GeniusAPI/bin/
10+
GeniusAPI/obj/
11+
12+
GeniusAPI.Tests/bin/
13+
GeniusAPI.Tests/obj/

GeniusAPI.sln

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.11.35017.193
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeniusAPI", "D:\GeniusAPI\GeniusAPI\GeniusAPI.csproj", "{227E9F91-03F2-410A-A920-6311907AEF46}"
7+
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeniusAPI.Tests", "D:\GeniusAPI\GeniusAPI.Tests\GeniusAPI.Tests.csproj", "{54D13AF9-5F93-4376-A3D5-48A7524C7E22}"
9+
EndProject
10+
Global
11+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
12+
Debug|Any CPU = Debug|Any CPU
13+
Release|Any CPU = Release|Any CPU
14+
EndGlobalSection
15+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
16+
{227E9F91-03F2-410A-A920-6311907AEF46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
17+
{227E9F91-03F2-410A-A920-6311907AEF46}.Debug|Any CPU.Build.0 = Debug|Any CPU
18+
{227E9F91-03F2-410A-A920-6311907AEF46}.Release|Any CPU.ActiveCfg = Release|Any CPU
19+
{227E9F91-03F2-410A-A920-6311907AEF46}.Release|Any CPU.Build.0 = Release|Any CPU
20+
{54D13AF9-5F93-4376-A3D5-48A7524C7E22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21+
{54D13AF9-5F93-4376-A3D5-48A7524C7E22}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{54D13AF9-5F93-4376-A3D5-48A7524C7E22}.Release|Any CPU.ActiveCfg = Release|Any CPU
23+
{54D13AF9-5F93-4376-A3D5-48A7524C7E22}.Release|Any CPU.Build.0 = Release|Any CPU
24+
EndGlobalSection
25+
GlobalSection(SolutionProperties) = preSolution
26+
HideSolutionNode = FALSE
27+
EndGlobalSection
28+
GlobalSection(ExtensibilityGlobals) = postSolution
29+
SolutionGuid = {40FCAF49-33C4-4323-8F63-3DB963C14413}
30+
EndGlobalSection
31+
EndGlobal

GeniusAPI/GeniusAPI.csproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net8.0</TargetFramework>
4+
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
13+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
14+
</ItemGroup>
15+
</Project>

GeniusAPI/LyricsClient.cs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using GeniusAPI.Internal.Models;
2+
using GeniusAPI.Models;
3+
using HtmlAgilityPack;
4+
using Microsoft.Extensions.Logging;
5+
using System.Net;
6+
using System.Text;
7+
using System.Text.Json;
8+
9+
namespace GeniusAPI;
10+
11+
/// <summary>
12+
/// Allows to search and fetch track lyrics on Genius.
13+
/// </summary>
14+
public class LyricsClient
15+
{
16+
readonly ILogger<LyricsClient>? logger = null;
17+
18+
readonly HttpClient client = new();
19+
20+
/// <summary>
21+
/// Creates a new LyricsClient
22+
/// </summary>
23+
/// <param name="accessToken">The access token for the Genius API. Get one for free here: https://genius.com/api-clients</param>
24+
public LyricsClient(
25+
string accessToken)
26+
{
27+
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
28+
}
29+
30+
/// <summary>
31+
/// Creates a new LyricsClient
32+
/// </summary>
33+
/// <param name="accessToken">The access token for the Genius API. Get one for free here: https://genius.com/api-clients</param>
34+
/// <param name="logger">The logger used to log.</param>
35+
public LyricsClient(
36+
string accessToken,
37+
ILogger<LyricsClient> logger)
38+
{
39+
this.logger = logger;
40+
41+
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
42+
}
43+
44+
45+
/// <summary>
46+
/// Searches for tracks on Genius.
47+
/// </summary>
48+
/// <param name="query">The query to search for.</param>
49+
/// <param name="cancellationToken">The token to cancel this action.</param>
50+
/// <returns>An array containing all found tracks.</returns>
51+
public async Task<IEnumerable<LyricsTrack>> SearchTracksAsync(
52+
string query,
53+
CancellationToken cancellationToken = default)
54+
{
55+
logger?.LogInformation("[LyricsClient-SearchTracksAsync] Searching for tracks on Genius...");
56+
HttpResponseMessage response = await client.GetAsync($"https://api.genius.com/search?q={WebUtility.UrlEncode(query)}", cancellationToken);
57+
response.EnsureSuccessStatusCode();
58+
59+
logger?.LogInformation("[LyricsClient-SearchTracksAsync] Parsing search results...");
60+
string body = await response.Content.ReadAsStringAsync(cancellationToken);
61+
62+
LyricsRequestResult? result = JsonSerializer.Deserialize<LyricsRequestResult>(body);
63+
if (result is null)
64+
{
65+
logger?.LogError("[LyricsClient-SearchTracksAsync] Failed to search for tracks on Genius: Parsed search result is null");
66+
throw new NullReferenceException("Failed to search for tracks on Genius.");
67+
}
68+
if (result.Meta.StatusCode != 200)
69+
{
70+
logger?.LogError("[LyricsClient-SearchTracksAsync] Failed to search for lyrics: {statusCode}, {message}", result.Meta.StatusCode, result.Meta.Message);
71+
throw new Exception($"Lyrics request did not return successful status code: {result.Meta.StatusCode}.", new(result.Meta.Message ?? "Unknown message."));
72+
}
73+
74+
return result.Response.Hits
75+
.Where(hit => hit.Type == "song")
76+
.Select(hit => hit.Track);
77+
}
78+
79+
80+
static void ExtractText(
81+
HtmlNode node,
82+
StringBuilder builder)
83+
{
84+
foreach (HtmlNode childNode in node.ChildNodes)
85+
switch (childNode.NodeType)
86+
{
87+
case HtmlNodeType.Text:
88+
builder.Append(childNode.InnerText);
89+
break;
90+
case HtmlNodeType.Element:
91+
if (childNode.Name == "br")
92+
builder.AppendLine();
93+
else
94+
ExtractText(childNode, builder);
95+
break;
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Fetches the lyrics of a track on Genius.
101+
/// </summary>
102+
/// <param name="url">The url of the track to fetch the lyrics for.</param>
103+
/// <param name="cancellationToken">The token to cancel this action.</param>
104+
/// <returns>A string representing the lyrics.</returns>
105+
public async Task<string> FetchLyricsAsync(
106+
string url,
107+
CancellationToken cancellationToken = default)
108+
{
109+
logger?.LogInformation("[LyricsClient-FetchLyricsAsync] Fetching track lyrics...");
110+
HttpResponseMessage response = await client.GetAsync(url, cancellationToken);
111+
response.EnsureSuccessStatusCode();
112+
113+
logger?.LogInformation("[LyricsClient-FetchLyricsAsync] Parsing track lyrics...");
114+
HtmlDocument html = new();
115+
html.Load(await response.Content.ReadAsStreamAsync(cancellationToken));
116+
117+
HtmlNodeCollection nodes = html.DocumentNode.SelectNodes("//div[@data-lyrics-container]");
118+
if (nodes is null || nodes.Count == 0)
119+
{
120+
logger?.LogError("[LyricsClient-FetchLyricsAsync] Failed to fetch track lyrics: Parsed HTML nodes is null or empty");
121+
throw new NullReferenceException("Failed to fetch track lyrics.");
122+
}
123+
124+
StringBuilder builder = new();
125+
foreach (HtmlNode node in nodes)
126+
{
127+
ExtractText(node, builder);
128+
builder.AppendLine();
129+
}
130+
131+
return WebUtility.HtmlDecode(builder.ToString().TrimEnd());
132+
}
133+
}

0 commit comments

Comments
 (0)