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 BaseUrl = "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 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;
///
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 = new UriBuilder(BaseUrl)
{
Path = $"/api/get/{splitId[0]}"
};
var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetFromJsonAsync(requestUri.Uri, cancellationToken: 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}");
}
}
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;
}
private static bool IsSupportedLyricSuffix(string suffix)
{
return string.Equals(suffix, SyncedSuffix, StringComparison.OrdinalIgnoreCase)
|| string.Equals(suffix, PlainSuffix, StringComparison.OrdinalIgnoreCase);
}
private async Task> GetExactMatch(
LyricSearchRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.SongName))
{
_logger.LogInformation("Song name is required");
return Enumerable.Empty();
}
string artist;
if (request.ArtistNames is not null
&& request.ArtistNames.Count > 0)
{
artist = request.ArtistNames[0];
}
else
{
_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 = new UriBuilder(BaseUrl)
{
Path = "/api/get",
Query = queryStringBuilder.ToString()
};
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var response = await httpClient
.GetFromJsonAsync(requestUri.Uri, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
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 = request.ArtistNames is { Count: > 0 } ? request.ArtistNames[0] : null;
var trackName = CleanSongName(request.SongName, rawArtist);
var albumName = ExcludeAlbumName ? null : request.AlbumName;
var artists = ExcludeArtistName ? [] : SplitArtists(rawArtist);
_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(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(trackName, artist, null, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
}
}
// Final fallback: try with just track name (no artist, no album).
if (artists.Count > 0)
{
_logger.LogDebug("No results with any artist, retrying with only track name for {Track}", trackName);
var fallbackResults = await SearchLrclib(trackName, null, null, cancellationToken).ConfigureAwait(false);
if (fallbackResults.Count > 0)
{
return fallbackResults.OrderByDescending(x => x.Metadata.IsSynced);
}
}
else
{
// No artist info at all — search by track name only.
var results = await SearchLrclib(trackName, null, albumName, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
if (!string.IsNullOrEmpty(albumName))
{
results = await SearchLrclib(trackName, null, null, cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.OrderByDescending(x => x.Metadata.IsSynced);
}
}
}
return Enumerable.Empty();
}
private async Task> SearchLrclib(
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 = new UriBuilder(BaseUrl)
{
Path = "/api/search",
Query = queryStringBuilder.ToString()
};
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var response = await httpClient
.GetFromJsonAsync>(requestUri.Uri, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
return [];
}
var results = new List();
foreach (var item in response)
{
results.AddRange(GetRemoteLyrics(item));
}
return results;
}
private List GetRemoteLyrics(LyricsSearchResponse response)
{
var results = new List();
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;
}
}