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; /// /// Lyric Manager. /// public class LyricManager : ILyricManager { private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// The list of . /// The list of . public LyricManager( ILogger logger, IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IMediaSourceManager mediaSourceManager, IEnumerable lyricProviders, IEnumerable 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(); } /// public event EventHandler? LyricDownloadFailure; /// public Task> 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); } /// public async Task> 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(); } /// public Task 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); } /// public async Task 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; } } /// public async Task UploadLyricAsync(Audio audio, LyricResponse lyricResponse) { 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; } /// public async Task 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); } /// public Task DeleteLyricsAsync(Audio audio) { ArgumentNullException.ThrowIfNull(audio); var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery { ItemId = audio.Id, Type = MediaStreamType.Lyric }); foreach (var stream in streams) { var path = stream.Path; _libraryMonitor.ReportFileSystemChangeBeginning(path); try { _fileSystem.DeleteFile(path); } finally { _libraryMonitor.ReportFileSystemChangeComplete(path, false); } } return audio.RefreshMetadata(CancellationToken.None); } /// public IReadOnlyList GetSupportedProviders(BaseItem item) { if (item is not Audio) { return []; } return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList(); } /// public async Task 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 parsedLyrics = parser.ParseLyrics(lyricFile); if (parsedLyrics is not null) { return parsedLyrics; } } } return null; } 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 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 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> 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(); 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) { var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia; var memoryStream = new MemoryStream(); await using (memoryStream.ConfigureAwait(false)) { var stream = lyricResponse.Stream; await using (stream.ConfigureAwait(false)) { stream.Seek(0, SeekOrigin.Begin); await stream.CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Seek(0, SeekOrigin.Begin); } var savePaths = new List(); var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant(); if (saveInMediaFolder) { 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."); } } } private async Task TrySaveToFiles(Stream stream, List savePaths) { List? 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); } } }