|
@@ -1,8 +1,25 @@
|
|
|
+using System;
|
|
|
using System.Collections.Generic;
|
|
|
+using System.Globalization;
|
|
|
+using System.IO;
|
|
|
using System.Linq;
|
|
|
+using System.Text;
|
|
|
+using System.Threading;
|
|
|
using System.Threading.Tasks;
|
|
|
+using Jellyfin.Extensions;
|
|
|
+using MediaBrowser.Common.Extensions;
|
|
|
using MediaBrowser.Controller.Entities;
|
|
|
+using MediaBrowser.Controller.Entities.Audio;
|
|
|
+using MediaBrowser.Controller.Library;
|
|
|
using MediaBrowser.Controller.Lyrics;
|
|
|
+using MediaBrowser.Controller.Persistence;
|
|
|
+using MediaBrowser.Controller.Providers;
|
|
|
+using MediaBrowser.Model.Configuration;
|
|
|
+using MediaBrowser.Model.Entities;
|
|
|
+using MediaBrowser.Model.IO;
|
|
|
+using MediaBrowser.Model.Lyrics;
|
|
|
+using MediaBrowser.Model.Providers;
|
|
|
+using Microsoft.Extensions.Logging;
|
|
|
|
|
|
namespace MediaBrowser.Providers.Lyric;
|
|
|
|
|
@@ -11,37 +28,246 @@ namespace MediaBrowser.Providers.Lyric;
|
|
|
/// </summary>
|
|
|
public class LyricManager : ILyricManager
|
|
|
{
|
|
|
+ private readonly ILogger<LyricManager> _logger;
|
|
|
+ private readonly IFileSystem _fileSystem;
|
|
|
+ private readonly ILibraryMonitor _libraryMonitor;
|
|
|
+ private readonly IMediaSourceManager _mediaSourceManager;
|
|
|
+
|
|
|
private readonly ILyricProvider[] _lyricProviders;
|
|
|
private readonly ILyricParser[] _lyricParsers;
|
|
|
|
|
|
/// <summary>
|
|
|
/// Initializes a new instance of the <see cref="LyricManager"/> class.
|
|
|
/// </summary>
|
|
|
- /// <param name="lyricProviders">All found lyricProviders.</param>
|
|
|
- /// <param name="lyricParsers">All found lyricParsers.</param>
|
|
|
- public LyricManager(IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers)
|
|
|
+ /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
|
|
|
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
|
|
+ /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
|
|
|
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
|
|
+ /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
|
|
|
+ /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
|
|
|
+ public LyricManager(
|
|
|
+ ILogger<LyricManager> logger,
|
|
|
+ IFileSystem fileSystem,
|
|
|
+ ILibraryMonitor libraryMonitor,
|
|
|
+ IMediaSourceManager mediaSourceManager,
|
|
|
+ IEnumerable<ILyricProvider> lyricProviders,
|
|
|
+ IEnumerable<ILyricParser> lyricParsers)
|
|
|
+ {
|
|
|
+ _logger = logger;
|
|
|
+ _fileSystem = fileSystem;
|
|
|
+ _libraryMonitor = libraryMonitor;
|
|
|
+ _mediaSourceManager = mediaSourceManager;
|
|
|
+ _lyricProviders = lyricProviders
|
|
|
+ .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
|
|
+ .ToArray();
|
|
|
+ _lyricParsers = lyricParsers
|
|
|
+ .OrderBy(l => l.Priority)
|
|
|
+ .ToArray();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+
|
|
|
+ var request = new LyricSearchRequest
|
|
|
+ {
|
|
|
+ MediaPath = audio.Path,
|
|
|
+ SongName = audio.Name,
|
|
|
+ AlbumName = audio.Album,
|
|
|
+ ArtistNames = audio.GetAllArtists().ToList(),
|
|
|
+ Duration = audio.RunTimeTicks,
|
|
|
+ IsAutomated = isAutomated
|
|
|
+ };
|
|
|
+
|
|
|
+ return SearchLyricsAsync(request, cancellationToken);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentNullException.ThrowIfNull(request);
|
|
|
+
|
|
|
+ var providers = _lyricProviders
|
|
|
+ .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
|
|
|
+ .OrderBy(i =>
|
|
|
+ {
|
|
|
+ var index = request.LyricFetcherOrder.IndexOf(i.Name);
|
|
|
+ return index == -1 ? int.MaxValue : index;
|
|
|
+ })
|
|
|
+ .ToArray();
|
|
|
+
|
|
|
+ // If not searching all, search one at a time until something is found
|
|
|
+ if (!request.SearchAllProviders)
|
|
|
+ {
|
|
|
+ foreach (var provider in providers)
|
|
|
+ {
|
|
|
+ var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
|
|
|
+ if (providerResult.Count > 0)
|
|
|
+ {
|
|
|
+ return providerResult;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false));
|
|
|
+
|
|
|
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
|
|
+
|
|
|
+ return results.SelectMany(i => i).ToArray();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
|
|
|
+
|
|
|
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
|
|
|
+
|
|
|
+ return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+ ArgumentNullException.ThrowIfNull(libraryOptions);
|
|
|
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
|
|
|
+
|
|
|
+ var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
|
|
|
+ if (provider is null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
|
|
|
+ if (response is null)
|
|
|
+ {
|
|
|
+ _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
+ if (parsedLyrics is null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ await TrySaveLyric(audio, libraryOptions, response).ConfigureAwait(false);
|
|
|
+ return parsedLyrics;
|
|
|
+ }
|
|
|
+ catch (RateLimitExceededException)
|
|
|
+ {
|
|
|
+ throw;
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
|
|
|
+ {
|
|
|
+ Item = audio,
|
|
|
+ Exception = ex,
|
|
|
+ Provider = provider.Name
|
|
|
+ });
|
|
|
+
|
|
|
+ throw;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse)
|
|
|
{
|
|
|
- _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
|
|
|
- _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+ ArgumentNullException.ThrowIfNull(lyricResponse);
|
|
|
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
|
|
|
+
|
|
|
+ var parsed = await InternalParseRemoteLyricsAsync(lyricResponse, CancellationToken.None).ConfigureAwait(false);
|
|
|
+ if (parsed is null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ await TrySaveLyric(audio, libraryOptions, lyricResponse).ConfigureAwait(false);
|
|
|
+ return parsed;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentException.ThrowIfNullOrEmpty(id);
|
|
|
+
|
|
|
+ var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
|
|
|
+ if (lyricResponse is null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return await InternalParseRemoteLyricsAsync(lyricResponse, cancellationToken).ConfigureAwait(false);
|
|
|
}
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
- public async Task<LyricResponse?> GetLyrics(BaseItem item)
|
|
|
+ public Task DeleteLyricsAsync(Audio audio)
|
|
|
{
|
|
|
- foreach (ILyricProvider provider in _lyricProviders)
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+ var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
|
|
|
+ {
|
|
|
+ ItemId = audio.Id,
|
|
|
+ Type = MediaStreamType.Lyric
|
|
|
+ });
|
|
|
+
|
|
|
+ foreach (var stream in streams)
|
|
|
{
|
|
|
- var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
|
|
|
- if (lyrics is null)
|
|
|
+ var path = stream.Path;
|
|
|
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
|
|
|
+
|
|
|
+ try
|
|
|
{
|
|
|
- continue;
|
|
|
+ _fileSystem.DeleteFile(path);
|
|
|
}
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _libraryMonitor.ReportFileSystemChangeComplete(path, false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return audio.RefreshMetadata(CancellationToken.None);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
|
|
|
+ {
|
|
|
+ if (item is not Audio)
|
|
|
+ {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList();
|
|
|
+ }
|
|
|
|
|
|
- foreach (ILyricParser parser in _lyricParsers)
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentNullException.ThrowIfNull(audio);
|
|
|
+
|
|
|
+ var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
|
|
|
+ foreach (var lyricStream in lyricStreams)
|
|
|
+ {
|
|
|
+ var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
|
|
+
|
|
|
+ var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
|
|
|
+ foreach (var parser in _lyricParsers)
|
|
|
{
|
|
|
- var result = parser.ParseLyrics(lyrics);
|
|
|
- if (result is not null)
|
|
|
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
|
|
|
+ if (parsedLyrics is not null)
|
|
|
{
|
|
|
- return result;
|
|
|
+ return parsedLyrics;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
@@ -49,22 +275,180 @@ public class LyricManager : ILyricManager
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- /// <inheritdoc />
|
|
|
- public bool HasLyricFile(BaseItem item)
|
|
|
+ private ILyricProvider? GetProvider(string providerId)
|
|
|
+ {
|
|
|
+ var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
|
|
|
+ if (provider is null)
|
|
|
+ {
|
|
|
+ _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
|
|
|
+ }
|
|
|
+
|
|
|
+ return provider;
|
|
|
+ }
|
|
|
+
|
|
|
+ private string GetProviderId(string name)
|
|
|
+ => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
|
|
+
|
|
|
+ private async Task<LyricDto?> InternalParseRemoteLyricsAsync(LyricResponse lyricResponse, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ lyricResponse.Stream.Seek(0, SeekOrigin.Begin);
|
|
|
+ using var streamReader = new StreamReader(lyricResponse.Stream, leaveOpen: true);
|
|
|
+ var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
|
|
+ var lyricFile = new LyricFile($"lyric.{lyricResponse.Format}", lyrics);
|
|
|
+ foreach (var parser in _lyricParsers)
|
|
|
+ {
|
|
|
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
|
|
|
+ if (parsedLyrics is not null)
|
|
|
+ {
|
|
|
+ return parsedLyrics;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
|
|
+ var parts = id.Split('_', 2);
|
|
|
+ var provider = GetProvider(parts[0]);
|
|
|
+ if (provider is null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ id = parts[^1];
|
|
|
+
|
|
|
+ return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
|
|
|
+ ILyricProvider provider,
|
|
|
+ LyricSearchRequest request,
|
|
|
+ CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var providerId = GetProviderId(provider.Name);
|
|
|
+ var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
+ var parsedResults = new List<RemoteLyricInfoDto>();
|
|
|
+ foreach (var result in searchResults)
|
|
|
+ {
|
|
|
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics, cancellationToken).ConfigureAwait(false);
|
|
|
+ if (parsedLyrics is null)
|
|
|
+ {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ parsedLyrics.Metadata = result.Metadata;
|
|
|
+ parsedResults.Add(new RemoteLyricInfoDto
|
|
|
+ {
|
|
|
+ Id = $"{providerId}_{result.Id}",
|
|
|
+ ProviderName = result.ProviderName,
|
|
|
+ Lyrics = parsedLyrics
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return parsedResults;
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task TrySaveLyric(
|
|
|
+ Audio audio,
|
|
|
+ LibraryOptions libraryOptions,
|
|
|
+ LyricResponse lyricResponse)
|
|
|
{
|
|
|
- foreach (ILyricProvider provider in _lyricProviders)
|
|
|
+ var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
|
|
|
+
|
|
|
+ var memoryStream = new MemoryStream();
|
|
|
+ await using (memoryStream.ConfigureAwait(false))
|
|
|
{
|
|
|
- if (item is null)
|
|
|
+ var stream = lyricResponse.Stream;
|
|
|
+
|
|
|
+ await using (stream.ConfigureAwait(false))
|
|
|
{
|
|
|
- continue;
|
|
|
+ stream.Seek(0, SeekOrigin.Begin);
|
|
|
+ await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
|
|
|
+ memoryStream.Seek(0, SeekOrigin.Begin);
|
|
|
}
|
|
|
|
|
|
- if (provider.HasLyrics(item))
|
|
|
+ var savePaths = new List<string>();
|
|
|
+ var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
|
|
|
+
|
|
|
+ if (saveInMediaFolder)
|
|
|
{
|
|
|
- return true;
|
|
|
+ var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
|
|
|
+ // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
|
|
|
+ if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
|
|
|
+ {
|
|
|
+ savePaths.Add(mediaFolderPath);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
|
|
|
+
|
|
|
+ // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
|
|
|
+ if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
|
|
|
+ {
|
|
|
+ savePaths.Add(internalPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (savePaths.Count > 0)
|
|
|
+ {
|
|
|
+ await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
|
|
|
}
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- return false;
|
|
|
+ private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
|
|
|
+ {
|
|
|
+ List<Exception>? exs = null;
|
|
|
+
|
|
|
+ foreach (var savePath in savePaths)
|
|
|
+ {
|
|
|
+ _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
|
|
|
+
|
|
|
+ _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
|
|
|
+
|
|
|
+ var fileOptions = AsyncFile.WriteOptions;
|
|
|
+ fileOptions.Mode = FileMode.Create;
|
|
|
+ fileOptions.PreallocationSize = stream.Length;
|
|
|
+ var fs = new FileStream(savePath, fileOptions);
|
|
|
+ await using (fs.ConfigureAwait(false))
|
|
|
+ {
|
|
|
+ await stream.CopyToAsync(fs).ConfigureAwait(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ (exs ??= []).Add(ex);
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ stream.Position = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (exs is not null)
|
|
|
+ {
|
|
|
+ throw new AggregateException(exs);
|
|
|
+ }
|
|
|
}
|
|
|
}
|