diff options
Diffstat (limited to 'MediaBrowser.Providers/Lyric/LyricManager.cs')
| -rw-r--r-- | MediaBrowser.Providers/Lyric/LyricManager.cs | 428 |
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); + } } } |
