mirror of
https://github.com/Felitendo/jellyfin-plugin-lyrics.git
synced 2026-11-01 17:23:25 +01:00
479 lines
16 KiB
C#
479 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Lyric provider for Lyrics.
|
|
/// </summary>
|
|
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<LyricsProvider> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LyricsProvider"/> class.
|
|
/// </summary>
|
|
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
|
/// <param name="logger">Instance of the <see cref="ILogger{LyricsProvider}"/>.</param>
|
|
public LyricsProvider(IHttpClientFactory httpClientFactory, ILogger<LyricsProvider> 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;
|
|
|
|
/// <inheritdoc />
|
|
public string Name => LyricsPlugin.Instance!.Name;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IEnumerable<RemoteLyricInfo>> 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<RemoteLyricInfo>();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<LyricResponse?> 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<LyricsSearchResponse>(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<string> SplitArtists(string? artistName)
|
|
{
|
|
if (string.IsNullOrEmpty(artistName))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
string[] separators = ["/", ";", " & ", ", ", " feat. ", " ft. ", " featuring "];
|
|
var parts = new List<string> { 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<IEnumerable<RemoteLyricInfo>> GetExactMatch(
|
|
LyricSearchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrEmpty(request.SongName))
|
|
{
|
|
_logger.LogInformation("Song name is required");
|
|
return Enumerable.Empty<RemoteLyricInfo>();
|
|
}
|
|
|
|
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<RemoteLyricInfo>();
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(request.AlbumName))
|
|
{
|
|
_logger.LogInformation("Album name is required");
|
|
return Enumerable.Empty<RemoteLyricInfo>();
|
|
}
|
|
|
|
if (request.Duration is null)
|
|
{
|
|
_logger.LogInformation("Duration is required");
|
|
return Enumerable.Empty<RemoteLyricInfo>();
|
|
}
|
|
|
|
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<LyricsSearchResponse>(requestUri.Uri, cancellationToken: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (response is null)
|
|
{
|
|
return Enumerable.Empty<RemoteLyricInfo>();
|
|
}
|
|
|
|
return GetRemoteLyrics(response);
|
|
}
|
|
|
|
private async Task<IEnumerable<RemoteLyricInfo>> GetFuzzyMatch(
|
|
LyricSearchRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrEmpty(request.SongName))
|
|
{
|
|
_logger.LogInformation("Song name is required");
|
|
return Enumerable.Empty<RemoteLyricInfo>();
|
|
}
|
|
|
|
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<RemoteLyricInfo>();
|
|
}
|
|
|
|
private async Task<List<RemoteLyricInfo>> 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<IReadOnlyList<LyricsSearchResponse>>(requestUri.Uri, cancellationToken: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (response is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var results = new List<RemoteLyricInfo>();
|
|
foreach (var item in response)
|
|
{
|
|
results.AddRange(GetRemoteLyrics(item));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private List<RemoteLyricInfo> GetRemoteLyrics(LyricsSearchResponse response)
|
|
{
|
|
var results = new List<RemoteLyricInfo>();
|
|
|
|
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;
|
|
}
|
|
}
|