SearchLyricAsync(Uri uri, CancellationToken cancellationToken = default)
@@ -86,14 +84,12 @@ public void WithWebClient(IWebClient webClient)
public void Enable()
{
- if (Options != null)
- Options.Enabled = true;
+ Options?.Enabled = true;
}
public void Disable()
{
- if (Options != null)
- Options.Enabled = false;
+ Options?.Enabled = false;
}
public virtual void WithLogger(ILoggerFactory loggerFactory)
diff --git a/LyricsScraperNET/Providers/Genius/GeniusOptions.cs b/LyricsScraperNET/Providers/Genius/GeniusOptions.cs
index d37d292..c57ff62 100644
--- a/LyricsScraperNET/Providers/Genius/GeniusOptions.cs
+++ b/LyricsScraperNET/Providers/Genius/GeniusOptions.cs
@@ -9,7 +9,7 @@ public sealed class GeniusOptions : IExternalProviderOptionsWithApiKey
public bool Enabled { get; set; }
// Optional. Use to retrieve lyric url for provided artist and song.
- public string ApiKey { get; set; }
+ public string ApiKey { get; set; } = string.Empty;
public string ConfigurationSectionName { get; } = "GeniusOptions";
diff --git a/LyricsScraperNET/Providers/Genius/GeniusProvider.cs b/LyricsScraperNET/Providers/Genius/GeniusProvider.cs
index 6b66df9..44ec2cd 100644
--- a/LyricsScraperNET/Providers/Genius/GeniusProvider.cs
+++ b/LyricsScraperNET/Providers/Genius/GeniusProvider.cs
@@ -176,7 +176,7 @@ private string GetParsedLyricFromHtmlPageBody(string htmlPageBody, out bool inst
var referentFragmentNodes = htmlDocument.DocumentNode.SelectNodes(_referentFragmentNodesXPath);
if (referentFragmentNodes != null)
foreach (HtmlNode fragmentNode in referentFragmentNodes)
- fragmentNode.ParentNode.ReplaceChild(htmlDocument.CreateTextNode(fragmentNode.ChildNodes[0].InnerHtml), fragmentNode);
+ fragmentNode.ParentNode?.ReplaceChild(htmlDocument.CreateTextNode(fragmentNode.ChildNodes[0].InnerHtml), fragmentNode);
var spanNodes = htmlDocument.DocumentNode.SelectNodes("//span");
if (spanNodes != null)
foreach (HtmlNode spanNode in spanNodes)
@@ -227,7 +227,7 @@ private string GetLyricUrlFromSearchResponse(SearchResponse searchResponse, stri
return artistAndSongHit.Result.Url;
}
- private string GetApiSearchQuery(string artist, string song)
+ private static string GetApiSearchQuery(string artist, string song)
=> string.Format(GeniusSearchQueryFormat, artist, song);
}
}
diff --git a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsOptions.cs b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsOptions.cs
index 5e6da84..cae52a6 100644
--- a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsOptions.cs
+++ b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsOptions.cs
@@ -22,12 +22,7 @@ public override bool Equals(object? obj)
public override int GetHashCode()
{
- unchecked
- {
- int hash = 17;
- hash = (hash * 31) + ExternalProviderType.GetHashCode();
- return hash;
- }
+ return System.HashCode.Combine(ExternalProviderType);
}
}
}
\ No newline at end of file
diff --git a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsParser.cs b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsParser.cs
index 6136d8f..f3e44d1 100644
--- a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsParser.cs
+++ b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsParser.cs
@@ -13,7 +13,7 @@ public string Parse(string lyric)
htmlDoc.LoadHtml(lyric);
var deEntitizedText = string.Join("\n\n", // -> \n\n
- htmlDoc.DocumentNode.SelectNodes("//p")
+ htmlDoc!.DocumentNode!.SelectNodes("//p")!
.Select(node => HtmlEntity.DeEntitize(node.InnerHtml
.Replace("
", "\n") // the trailing whitespace after
is necessary
.Trim()
diff --git a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsProvider.cs b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsProvider.cs
index 8901e9e..db02ba0 100644
--- a/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsProvider.cs
+++ b/LyricsScraperNET/Providers/KPopLyrics/KPopLyricsProvider.cs
@@ -119,7 +119,7 @@ private SearchResult PostProcessLyric(Uri uri, string text)
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(text);
- var mainNode = htmlDoc.DocumentNode.SelectNodes(LyricsContainerNodesXPath).FirstOrDefault();
+ var mainNode = htmlDoc.DocumentNode?.SelectNodes(LyricsContainerNodesXPath)?.FirstOrDefault();
if (mainNode is null)
{
@@ -133,9 +133,9 @@ private SearchResult PostProcessLyric(Uri uri, string text)
return new SearchResult(ExternalProviderType.KPopLyrics, ResponseStatusCode.NoDataFound);
}
- var h2Nodes = htmlDoc.DocumentNode.SelectNodes("//h2");
+ var h2Nodes = htmlDoc.DocumentNode?.SelectNodes("//h2");
- if (h2Nodes is null || !h2Nodes.Any())
+ if (h2Nodes is null || h2Nodes.Count == 0)
{
_logger?.LogWarning($"KPopLyrics. Can't parse lyric from the page. Couldn't find header nodes. Uri: {uri}");
return new SearchResult(ExternalProviderType.KPopLyrics, ResponseStatusCode.NoDataFound);
@@ -167,7 +167,7 @@ private SearchResult PostProcessLyric(Uri uri, string text)
return new SearchResult(result, ExternalProviderType.KPopLyrics);
}
- private string TakeParagraphsUntilHeader(HtmlNode startNode)
+ private static string TakeParagraphsUntilHeader(HtmlNode startNode)
{
var paragraphs = new List();
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibHttpClient.cs b/LyricsScraperNET/Providers/Lrclib/LrclibHttpClient.cs
new file mode 100644
index 0000000..8f7a382
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibHttpClient.cs
@@ -0,0 +1,113 @@
+using LyricsScraperNET.Common;
+using LyricsScraperNET.Network.Abstract;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ ///
+ /// HTTP client for LRCLIB. Identifies the library via User-Agent and honors Retry-After on 429.
+ ///
+ internal sealed class LrclibHttpClient : IWebClient
+ {
+ private readonly ILogger? _logger;
+ private static readonly HttpClient _httpClient = new();
+ private const int MaxAttempts = 2;
+ private static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(30);
+
+ public LrclibHttpClient()
+ {
+ }
+
+ public LrclibHttpClient(ILogger logger) : this()
+ {
+ _logger = logger;
+ }
+
+ public string Load(Uri uri, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return LoadAsync(uri, cancellationToken).GetAwaiter().GetResult();
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger?.LogWarning($"Lrclib HTTP request failed for uri: {uri}. Exception: {ex}");
+ return string.Empty;
+ }
+ }
+
+ public async Task LoadAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ for (int attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ using var request = CreateRequest(uri);
+ var response = await _httpClient.SendAsync(request, cancellationToken);
+
+ if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < MaxAttempts)
+ {
+ var delay = GetRetryAfterDelay(response);
+ _logger?.LogInformation($"Lrclib rate limited for uri: {uri}. Waiting {delay.TotalSeconds}s before retry.");
+ await Task.Delay(delay, cancellationToken);
+ continue;
+ }
+
+ // 404 body is JSON with TrackNotFound and is handled by the provider.
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return await response.Content.ReadAsStringAsync(cancellationToken);
+ }
+
+ response.EnsureSuccessStatusCode();
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ _logger?.LogDebug($"Lrclib returned empty content for uri: {uri}");
+ }
+
+ return content;
+ }
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger?.LogWarning($"Lrclib HTTP request failed for URI: {uri}. Exception: {ex}");
+ return string.Empty;
+ }
+ catch (OperationCanceledException ex)
+ {
+ _logger?.LogInformation($"Lrclib request for URI: {uri} was canceled. Exception: {ex}");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError($"An unexpected error occurred while loading Lrclib URI: {uri}. Exception: {ex}");
+ return string.Empty;
+ }
+
+ return string.Empty;
+ }
+
+ private static HttpRequestMessage CreateRequest(Uri uri)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Headers.TryAddWithoutValidation("User-Agent", Constants.LibraryUserAgent);
+ request.Headers.TryAddWithoutValidation("X-User-Agent", Constants.LibraryUserAgent);
+ return request;
+ }
+
+ private static TimeSpan GetRetryAfterDelay(HttpResponseMessage response)
+ {
+ var retryAfter = response.Headers.RetryAfter?.Delta;
+ if (retryAfter == null || retryAfter.Value <= TimeSpan.Zero)
+ return TimeSpan.FromSeconds(1);
+
+ return retryAfter.Value > MaxRetryAfter ? MaxRetryAfter : retryAfter.Value;
+ }
+ }
+}
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibOptions.cs b/LyricsScraperNET/Providers/Lrclib/LrclibOptions.cs
new file mode 100644
index 0000000..f9b5ecb
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibOptions.cs
@@ -0,0 +1,28 @@
+using LyricsScraperNET.Common;
+using LyricsScraperNET.Providers.Abstract;
+using LyricsScraperNET.Providers.Models;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ public sealed class LrclibOptions : IExternalProviderOptions
+ {
+ public bool Enabled { get; set; }
+
+ public ExternalProviderType ExternalProviderType => ExternalProviderType.Lrclib;
+
+ public int SearchPriority { get; set; } = Constants.ProvidersSearchPriorities[ExternalProviderType.Lrclib];
+
+ public string ConfigurationSectionName { get; } = "LrclibOptions";
+
+ public override bool Equals(object? obj)
+ {
+ return obj is LrclibOptions options &&
+ ExternalProviderType == options.ExternalProviderType;
+ }
+
+ public override int GetHashCode()
+ {
+ return System.HashCode.Combine(ExternalProviderType);
+ }
+ }
+}
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibParser.cs b/LyricsScraperNET/Providers/Lrclib/LrclibParser.cs
new file mode 100644
index 0000000..8265289
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibParser.cs
@@ -0,0 +1,12 @@
+using LyricsScraperNET.Providers.Abstract;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ internal sealed class LrclibParser : IExternalProviderLyricParser
+ {
+ public string Parse(string lyric)
+ {
+ return lyric?.Trim() ?? string.Empty;
+ }
+ }
+}
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibProvider.cs b/LyricsScraperNET/Providers/Lrclib/LrclibProvider.cs
new file mode 100644
index 0000000..7b9b2fb
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibProvider.cs
@@ -0,0 +1,155 @@
+using LyricsScraperNET.Extensions;
+using LyricsScraperNET.Helpers;
+using LyricsScraperNET.Models.Responses;
+using LyricsScraperNET.Providers.Abstract;
+using LyricsScraperNET.Providers.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ public sealed class LrclibProvider : ExternalProviderBase
+ {
+ private ILogger? _logger;
+ private readonly IExternalUriConverter _uriConverter;
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ #region Constructors
+
+ public LrclibProvider()
+ {
+ Parser = new LrclibParser();
+ WebClient = new LrclibHttpClient();
+ Options = new LrclibOptions() { Enabled = true };
+ _uriConverter = new LrclibUriConverter();
+ }
+
+ public LrclibProvider(ILogger logger, LrclibOptions options)
+ : this()
+ {
+ _logger = logger;
+ Ensure.ArgumentNotNull(options, nameof(options));
+ Options = options;
+ }
+
+ public LrclibProvider(ILogger logger, IOptionsSnapshot options)
+ : this(logger, options.Value)
+ {
+ Ensure.ArgumentNotNull(options, nameof(options));
+ }
+
+ public LrclibProvider(LrclibOptions options)
+ : this(NullLogger.Instance, options)
+ {
+ Ensure.ArgumentNotNull(options, nameof(options));
+ }
+
+ public LrclibProvider(IOptionsSnapshot options)
+ : this(NullLogger.Instance, options.Value)
+ {
+ Ensure.ArgumentNotNull(options, nameof(options));
+ }
+
+ #endregion
+
+ public override IExternalProviderOptions Options { get; }
+
+ #region Sync
+
+ protected override SearchResult SearchLyric(string artist, string song, CancellationToken cancellationToken = default)
+ {
+ return SearchLyricAsync(artist, song, cancellationToken).GetAwaiter().GetResult();
+ }
+
+ protected override SearchResult SearchLyric(Uri uri, CancellationToken cancellationToken = default)
+ {
+ return SearchLyricAsync(uri, cancellationToken).GetAwaiter().GetResult();
+ }
+
+ #endregion
+
+ #region Async
+
+ protected override async Task SearchLyricAsync(string artist, string song, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return await SearchLyricAsync(_uriConverter.GetLyricUri(artist, song), cancellationToken);
+ }
+
+ protected override async Task SearchLyricAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ if (WebClient == null || Parser == null)
+ {
+ _logger?.LogWarning($"Lrclib. Please set up WebClient and Parser first");
+ return new SearchResult(ExternalProviderType.Lrclib);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var text = await WebClient.LoadAsync(uri, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return PostProcessLyric(uri, text);
+ }
+
+ #endregion
+
+ public override void WithLogger(ILoggerFactory loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ private SearchResult PostProcessLyric(Uri uri, string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ _logger?.LogWarning($"Lrclib. Response is empty for Uri: [{uri}]");
+ return new SearchResult(ExternalProviderType.Lrclib);
+ }
+
+ LrclibTrackResponse? response;
+ try
+ {
+ response = JsonSerializer.Deserialize(text, JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ _logger?.LogWarning($"Lrclib. Failed to parse JSON for Uri: [{uri}]. Exception: {ex}");
+ return new SearchResult(ExternalProviderType.Lrclib);
+ }
+
+ if (response == null)
+ {
+ _logger?.LogWarning($"Lrclib. Empty parsed response for Uri: [{uri}]");
+ return new SearchResult(ExternalProviderType.Lrclib);
+ }
+
+ if (response.Code == 404 || string.Equals(response.Name, "TrackNotFound", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger?.LogInformation($"Lrclib. Track not found for Uri: [{uri}]");
+ return new SearchResult(ExternalProviderType.Lrclib, ResponseStatusCode.NoDataFound);
+ }
+
+ if (response.Instrumental)
+ return new SearchResult(ExternalProviderType.Lrclib).AddInstrumental(true);
+
+ if (string.IsNullOrWhiteSpace(response.PlainLyrics))
+ {
+ _logger?.LogWarning($"Lrclib. Can't find lyrics for Uri: [{uri}]");
+ return new SearchResult(ExternalProviderType.Lrclib);
+ }
+
+ var result = Parser.Parse(response.PlainLyrics);
+ return new SearchResult(result, ExternalProviderType.Lrclib);
+ }
+ }
+}
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibTrackResponse.cs b/LyricsScraperNET/Providers/Lrclib/LrclibTrackResponse.cs
new file mode 100644
index 0000000..a550119
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibTrackResponse.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ internal sealed class LrclibTrackResponse
+ {
+ [JsonPropertyName("instrumental")]
+ public bool Instrumental { get; set; }
+
+ [JsonPropertyName("plainLyrics")]
+ public string? PlainLyrics { get; set; }
+
+ [JsonPropertyName("code")]
+ public int? Code { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+ }
+}
diff --git a/LyricsScraperNET/Providers/Lrclib/LrclibUriConverter.cs b/LyricsScraperNET/Providers/Lrclib/LrclibUriConverter.cs
new file mode 100644
index 0000000..2846c5f
--- /dev/null
+++ b/LyricsScraperNET/Providers/Lrclib/LrclibUriConverter.cs
@@ -0,0 +1,21 @@
+using LyricsScraperNET.Providers.Abstract;
+using System;
+
+namespace LyricsScraperNET.Providers.Lrclib
+{
+ internal sealed class LrclibUriConverter : IExternalUriConverter
+ {
+ internal const string BaseApiUrl = "https://lrclib.net/api/get";
+
+ public Uri GetArtistUri(string artist)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Uri GetLyricUri(string artist, string song)
+ {
+ var query = $"artist_name={Uri.EscapeDataString(artist)}&track_name={Uri.EscapeDataString(song)}";
+ return new Uri($"{BaseApiUrl}?{query}");
+ }
+ }
+}
diff --git a/LyricsScraperNET/Providers/LyricFind/LyricFindProvider.cs b/LyricsScraperNET/Providers/LyricFind/LyricFindProvider.cs
index 7f8e462..da66447 100644
--- a/LyricsScraperNET/Providers/LyricFind/LyricFindProvider.cs
+++ b/LyricsScraperNET/Providers/LyricFind/LyricFindProvider.cs
@@ -136,7 +136,7 @@ private SearchResult PostProcessLyric(Uri uri, string text)
}
// Trim the beginning of the text to the lyrics
- text = text.Substring(startIndex + _lyricStart.Length + 1);
+ text = text[(startIndex + _lyricStart.Length + 1)..];
// Finding the end of the lyric text in the json field value.
int start = text.IndexOf("\"") + 1;
@@ -154,7 +154,7 @@ private SearchResult PostProcessLyric(Uri uri, string text)
return new SearchResult(Models.ExternalProviderType.LyricFind);
}
- string result = Parser.Parse(text.Substring(start, endOfLyricInJson - start));
+ string result = Parser.Parse(text[start..endOfLyricInJson]);
return new SearchResult(result, Models.ExternalProviderType.LyricFind);
}
@@ -163,7 +163,7 @@ private SearchResult PostProcessLyric(Uri uri, string text)
///
/// Check if lyric text contains region restricted information like viewable (false) and repsonse with code (206) and description.
///
- private bool IsRegionRestrictedLyric(string text)
+ private static bool IsRegionRestrictedLyric(string text)
{
return TryReturnBooleanFieldValue(text, _viewableStart, "false")
&& Regex.IsMatch(text, _lyricNotAvailablePattern);
@@ -172,7 +172,7 @@ private bool IsRegionRestrictedLyric(string text)
///
/// Check if lyric text contains instrumental flag.
///
- private bool IsInstumentalLyric(string text)
+ private static bool IsInstumentalLyric(string text)
{
return TryReturnBooleanFieldValue(text, _instrumentalStart)
|| TryReturnBooleanFieldValue(text, _songIsInstrumentalStart);
@@ -182,13 +182,13 @@ private bool IsInstumentalLyric(string text)
/// Try to find and return the fielad value as boolean. Pattern: [:true(or false)].
/// In case if fieldName is not found returns false.
///