using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Jellyfin.Plugin.Lyrics.Models;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Model.Lyrics;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Lyrics;
///
/// Lyric provider for Lyrics.
///
public class LyricsProvider : ILyricProvider
{
private const string DefaultBaseUrl = "https://lrclib.net";
private const string SyncedSuffix = "synced";
private const string PlainSuffix = "plain";
private const string SyncedFormat = "lrc";
private const string PlainFormat = "txt";
private static readonly string[] ParenthesizedNoise =
[
"(Official Music Video)",
"(Official Video)",
"(Official Audio)",
"(Official Lyric Video)",
"(Lyric Video)",
"(Music Video)",
"(Visualizer)",
"(Visualiser)",
"(Audio)",
"(Lyrics)",
"(MV)",
"(HD)",
"(HQ)",
"[Official Music Video]",
"[Official Video]",
"[Official Audio]",
"[Official Lyric Video]",
"[Lyric Video]",
"[Music Video]",
"[Visualizer]",
"[Visualiser]",
"[Audio]",
"[Lyrics]",
"[MV]",
"[HD]",
"[HQ]"
];
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// Instance of the interface.
/// Instance of the .
public LyricsProvider(IHttpClientFactory httpClientFactory, ILogger logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
private static string BaseUrl
{
get
{
var configured = LyricsPlugin.Instance?.Configuration.LrclibBaseUrl;
if (string.IsNullOrWhiteSpace(configured))
{
return DefaultBaseUrl;
}
var trimmed = configured.Trim().TrimEnd('/');
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
return trimmed;
}
return DefaultBaseUrl;
}
}
private static bool UseStrictSearch => LyricsPlugin.Instance?.Configuration.UseStrictSearch ?? true;
private static bool ExcludeArtistName => LyricsPlugin.Instance?.Configuration.ExcludeArtistName ?? false;
private static bool ExcludeAlbumName => LyricsPlugin.Instance?.Configuration.ExcludeAlbumName ?? false;
private static bool EnableDurationFilter => LyricsPlugin.Instance?.Configuration.EnableDurationFilter ?? true;
private static int DurationToleranceSeconds
{
get
{
var configured = LyricsPlugin.Instance?.Configuration.DurationToleranceSeconds ?? 15;
return configured > 0 ? configured : 15;
}
}
///
public string Name => LyricsPlugin.Instance!.Name;
///
public async Task> SearchAsync(
LyricSearchRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
return UseStrictSearch
? await GetExactMatch(request, cancellationToken).ConfigureAwait(false)
: await GetFuzzyMatch(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
var artist = request.ArtistNames is { Count: > 0 } ? request.ArtistNames[0] : null;
_logger.LogDebug(
ex,
"Unable to get results for {Artist} - {Album} - {Song}",
artist,
request.AlbumName,
request.SongName);
return Enumerable.Empty();
}
}
///
public async Task GetLyricsAsync(string id, CancellationToken cancellationToken)
{
var splitId = id.Split('_', 2);
if (splitId.Length != 2
|| string.IsNullOrWhiteSpace(splitId[0])
|| !IsSupportedLyricSuffix(splitId[1]))
{
_logger.LogDebug("Invalid lyric id format: {Id}", id);
throw new ResourceNotFoundException($"Unable to get results for id {id}");
}
try
{
var requestUri = BuildLrclibUri($"api/get/{splitId[0]}");
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var httpResponse = await LrclibRateLimiter.SendAsync(
httpClient,
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
_logger,
cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
var response = await httpResponse.Content
.ReadFromJsonAsync(cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
throw new ResourceNotFoundException($"Unable to get results for id {id}");
}
if (string.Equals(splitId[1], SyncedSuffix, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(response.SyncedLyrics))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.SyncedLyrics));
return new LyricResponse
{
Format = SyncedFormat,
Stream = stream
};
}
if (string.Equals(splitId[1], PlainSuffix, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(response.PlainLyrics))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.PlainLyrics));
return new LyricResponse
{
Format = PlainFormat,
Stream = stream
};
}
throw new ResourceNotFoundException($"Unable to get results for id {id}");
}
catch (HttpRequestException ex)
{
_logger.LogDebug(
ex,
"Unable to get results for id {Id}",
id);
throw new ResourceNotFoundException($"Unable to get results for id {id}");
}
}
// UriBuilder.Path overwrites any existing path, which breaks reverse-proxy
// self-hosts mounted under a subpath (e.g. https://example.com/lrclib).
// Compose against the base URI so the configured path is preserved.
private static Uri BuildLrclibUri(string relativePath, string? query = null)
{
var builder = new UriBuilder(BaseUrl);
var basePath = builder.Path ?? string.Empty;
builder.Path = basePath.TrimEnd('/') + "/" + relativePath.TrimStart('/');
if (!string.IsNullOrEmpty(query))
{
builder.Query = query;
}
return builder.Uri;
}
private static string CleanSongName(string songName, string? artistName)
{
var cleaned = songName;
// Strip "Artist - " prefix from title (common in YouTube downloads).
if (!string.IsNullOrEmpty(artistName))
{
var prefix = artistName + " - ";
if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
cleaned = cleaned[prefix.Length..];
}
}
// Strip parenthesized/bracketed YouTube-style suffixes.
foreach (var noise in ParenthesizedNoise)
{
var index = cleaned.IndexOf(noise, StringComparison.OrdinalIgnoreCase);
if (index >= 0)
{
cleaned = string.Concat(cleaned.AsSpan(0, index), cleaned.AsSpan(index + noise.Length));
}
}
// Strip trailing " - ArtistName" (duplicate artist in title).
if (!string.IsNullOrEmpty(artistName))
{
var suffix = " - " + artistName;
if (cleaned.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
cleaned = cleaned[..^suffix.Length];
}
}
return cleaned.Trim();
}
private static List SplitArtists(string? artistName)
{
if (string.IsNullOrEmpty(artistName))
{
return [];
}
string[] separators = ["/", ";", " & ", ", ", " feat. ", " ft. ", " featuring "];
var parts = new List { artistName };
foreach (var separator in separators)
{
if (artistName.Contains(separator, StringComparison.OrdinalIgnoreCase))
{
var split = artistName.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in split)
{
if (!string.IsNullOrWhiteSpace(part)
&& !parts.Contains(part, StringComparer.OrdinalIgnoreCase))
{
parts.Add(part);
}
}
}
}
return parts;
}
// Jellyfin fills ArtistNames from the track tag and AlbumArtistsNames from the album tag.
// Files carrying only an album artist (compilations, classical rips, many downloads) would
// otherwise be dropped before a single request goes out.
private static string? GetPrimaryArtist(LyricSearchRequest request)
{
if (request.ArtistNames is { Count: > 0 })
{
return request.ArtistNames[0];
}
if (request.AlbumArtistsNames is { Count: > 0 })
{
return request.AlbumArtistsNames[0];
}
return null;
}
// Every artist worth querying, most specific first: each tagged track artist together with the
// individual names its combined form splits into, then the album artists as a fallback.
private static List GetArtistCandidates(LyricSearchRequest request)
{
var candidates = new List();
AddArtistCandidates(candidates, request.ArtistNames);
AddArtistCandidates(candidates, request.AlbumArtistsNames);
return candidates;
}
private static void AddArtistCandidates(List candidates, IReadOnlyList? names)
{
if (names is null)
{
return;
}
foreach (var name in names)
{
foreach (var token in SplitArtists(name))
{
if (!candidates.Contains(token, StringComparer.OrdinalIgnoreCase))
{
candidates.Add(token);
}
}
}
}
private static bool IsSupportedLyricSuffix(string suffix)
{
return string.Equals(suffix, SyncedSuffix, StringComparison.OrdinalIgnoreCase)
|| string.Equals(suffix, PlainSuffix, StringComparison.OrdinalIgnoreCase);
}
// Reject results whose duration or artist is incompatible with the request, so a track named
// "Faded (Interlude)" can't be matched against an unrelated song with the same title.
private bool IsAcceptableMatch(LyricSearchRequest request, LyricsSearchResponse response)
{
// Tolerance accounts for trailing silence, LAME encoder padding, vinyl-rip fade-outs, and
// minor master/remaster differences. The artist check above does the heavy lifting; this
// is just a sanity net so a 30-second interlude can't get matched to a 4-minute pop song.
if (EnableDurationFilter && request.Duration is not null && response.Duration is not null)
{
var tolerance = DurationToleranceSeconds;
var requestSeconds = TimeSpan.FromTicks(request.Duration.Value).TotalSeconds;
var responseSeconds = response.Duration.Value;
if (Math.Abs(requestSeconds - responseSeconds) > tolerance)
{
_logger.LogDebug(
"Rejected LRCLIB match {Id}: duration mismatch (req {Requested:F0}s, got {Got:F0}s)",
response.Id,
requestSeconds,
responseSeconds);
return false;
}
}
var requestedArtists = GetArtistCandidates(request);
if (requestedArtists.Count > 0 && !string.IsNullOrEmpty(response.ArtistName))
{
var matched = false;
foreach (var token in requestedArtists)
{
if (response.ArtistName.Contains(token, StringComparison.OrdinalIgnoreCase))
{
matched = true;
break;
}
}
if (!matched)
{
_logger.LogDebug(
"Rejected LRCLIB match {Id}: artist mismatch (requested {Requested}, got {Got})",
response.Id,
requestedArtists,
response.ArtistName);
return false;
}
}
return true;
}
private async Task> GetExactMatch(
LyricSearchRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.SongName))
{
_logger.LogInformation("Song name is required");
return Enumerable.Empty();
}
var artist = GetPrimaryArtist(request);
if (string.IsNullOrEmpty(artist))
{
_logger.LogInformation("Artist name is required");
return Enumerable.Empty();
}
if (string.IsNullOrEmpty(request.AlbumName))
{
_logger.LogInformation("Album name is required");
return Enumerable.Empty();
}
if (request.Duration is null)
{
_logger.LogInformation("Duration is required");
return Enumerable.Empty();
}
var trackName = CleanSongName(request.SongName, artist);
var queryStringBuilder = new StringBuilder()
.Append("track_name=")
.Append(HttpUtility.UrlEncode(trackName))
.Append("&artist_name=")
.Append(HttpUtility.UrlEncode(artist))
.Append("&album_name=")
.Append(HttpUtility.UrlEncode(request.AlbumName))
.Append("&duration=")
.Append(TimeSpan.FromTicks(request.Duration.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture));
var requestUri = BuildLrclibUri("api/get", queryStringBuilder.ToString());
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var httpResponse = await LrclibRateLimiter.SendAsync(
httpClient,
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
_logger,
cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
var response = await httpResponse.Content
.ReadFromJsonAsync(cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
return Enumerable.Empty();
}
if (!IsAcceptableMatch(request, response))
{
return Enumerable.Empty();
}
return GetRemoteLyrics(response);
}
private async Task> GetFuzzyMatch(
LyricSearchRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.SongName))
{
_logger.LogInformation("Song name is required");
return Enumerable.Empty();
}
var rawArtist = GetPrimaryArtist(request);
var trackName = CleanSongName(request.SongName, rawArtist);
var albumName = ExcludeAlbumName ? null : request.AlbumName;
var artists = ExcludeArtistName ? [] : GetArtistCandidates(request);
_logger.LogDebug("Fuzzy search: original song name {Original}, cleaned {Cleaned}, artists {Artists}, album {Album}", request.SongName, trackName, artists, request.AlbumName);
// Try each artist variant (full combined name first, then individual artists).
foreach (var artist in artists)
{
var results = await SearchLrclib(request, trackName, artist, albumName, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
// Retry without album if we had one.
if (!string.IsNullOrEmpty(albumName))
{
_logger.LogDebug("No results with album for artist {Artist}, retrying without album for {Track}", artist, trackName);
results = await SearchLrclib(request, trackName, artist, null, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
}
}
// Track-name-only fallback only when no artist info exists at all.
// When the user has artists, a track-name-only search would let "Faded (Interlude)"
// match any "Faded" by any artist — exactly the bug we're trying to prevent.
if (artists.Count == 0)
{
var results = await SearchLrclib(request, trackName, null, albumName, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
if (!string.IsNullOrEmpty(albumName))
{
results = await SearchLrclib(request, trackName, null, null, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
}
}
return Enumerable.Empty();
}
private async Task> SearchLrclib(
LyricSearchRequest request,
string trackName,
string? artistName,
string? albumName,
CancellationToken cancellationToken)
{
var queryStringBuilder = new StringBuilder()
.Append("track_name=")
.Append(HttpUtility.UrlEncode(trackName));
if (!string.IsNullOrEmpty(artistName))
{
queryStringBuilder
.Append("&artist_name=")
.Append(HttpUtility.UrlEncode(artistName));
}
if (!string.IsNullOrEmpty(albumName))
{
queryStringBuilder
.Append("&album_name=")
.Append(HttpUtility.UrlEncode(albumName));
}
var requestUri = BuildLrclibUri("api/search", queryStringBuilder.ToString());
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var httpResponse = await LrclibRateLimiter.SendAsync(
httpClient,
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
_logger,
cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
var response = await httpResponse.Content
.ReadFromJsonAsync>(cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
return [];
}
var results = new List();
foreach (var item in response)
{
if (!IsAcceptableMatch(request, item))
{
continue;
}
results.AddRange(GetRemoteLyrics(item));
}
return results;
}
private List GetRemoteLyrics(LyricsSearchResponse response)
{
var results = new List();
if (response.Instrumental == true)
{
_logger.LogDebug("Skipping LRCLIB entry {Id} flagged as instrumental", response.Id);
return results;
}
if (!string.IsNullOrEmpty(response.SyncedLyrics))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.SyncedLyrics));
results.Add(new RemoteLyricInfo
{
Id = $"{response.Id}_{SyncedSuffix}",
ProviderName = Name,
Metadata = new LyricMetadata
{
Album = response.AlbumName,
Artist = response.ArtistName,
Title = response.TrackName,
Length = TimeSpan.FromSeconds(response.Duration ?? 0).Ticks,
IsSynced = true
},
Lyrics = new LyricResponse
{
Format = SyncedFormat,
Stream = stream
}
});
}
if (!string.IsNullOrEmpty(response.PlainLyrics))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(response.PlainLyrics));
results.Add(new RemoteLyricInfo
{
Id = $"{response.Id}_{PlainSuffix}",
ProviderName = Name,
Metadata = new LyricMetadata
{
Album = response.AlbumName,
Artist = response.ArtistName,
Title = response.TrackName,
Length = TimeSpan.FromSeconds(response.Duration ?? 0).Ticks,
IsSynced = false
},
Lyrics = new LyricResponse
{
Format = PlainFormat,
Stream = stream
}
});
}
return results;
}
}