123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- 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;
- /// <summary>
- /// Lyric Manager.
- /// </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="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,
- AlbumArtistsNames = audio.AlbumArtists,
- ArtistNames = audio.Artists,
- 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.Format, response.Stream, cancellationToken).ConfigureAwait(false);
- if (parsedLyrics is null)
- {
- return null;
- }
- await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).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?> SaveLyricAsync(Audio audio, string format, string lyrics)
- {
- ArgumentNullException.ThrowIfNull(audio);
- ArgumentException.ThrowIfNullOrEmpty(format);
- ArgumentException.ThrowIfNullOrEmpty(lyrics);
- var bytes = Encoding.UTF8.GetBytes(lyrics);
- using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
- return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
- }
- /// <inheritdoc />
- public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
- {
- ArgumentNullException.ThrowIfNull(audio);
- ArgumentException.ThrowIfNullOrEmpty(format);
- ArgumentNullException.ThrowIfNull(lyrics);
- var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
- var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
- if (parsed is null)
- {
- return null;
- }
- await TrySaveLyric(audio, libraryOptions, format, lyrics).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.Format, lyricResponse.Stream, cancellationToken).ConfigureAwait(false);
- }
- /// <inheritdoc />
- 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);
- }
- /// <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();
- }
- /// <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 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<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken cancellationToken)
- {
- lyricStream.Seek(0, SeekOrigin.Begin);
- using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
- var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- var lyricFile = new LyricFile($"lyric.{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.Format, result.Lyrics.Stream, 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,
- string format,
- Stream lyricStream)
- {
- var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
- var memoryStream = new MemoryStream();
- await using (memoryStream.ConfigureAwait(false))
- {
- await using (lyricStream.ConfigureAwait(false))
- {
- lyricStream.Seek(0, SeekOrigin.Begin);
- await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
- memoryStream.Seek(0, SeekOrigin.Begin);
- }
- var savePaths = new List<string>();
- var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + 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<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);
- }
- }
- }
|