aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Providers/Lyric/LyricManager.cs
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Providers/Lyric/LyricManager.cs')
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs428
1 files changed, 406 insertions, 22 deletions
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 6da811927..60734b89a 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -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);
+ }
}
}