< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Lyric.LyricManager
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Lyric/LyricManager.cs
Line coverage
22%
Covered lines: 11
Uncovered lines: 37
Coverable lines: 48
Total lines: 467
Line coverage: 22.9%
Branch coverage
0%
Covered branches: 0
Total branches: 6
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SearchLyricsAsync(...)100%210%
DownloadLyricsAsync(...)100%210%
DeleteLyricsAsync(...)0%620%
GetSupportedProviders(...)0%620%
GetProvider(...)0%620%
GetProviderId(...)100%210%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Lyric/LyricManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Common.Extensions;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Entities.Audio;
 13using MediaBrowser.Controller.Library;
 14using MediaBrowser.Controller.Lyrics;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Controller.Providers;
 17using MediaBrowser.Model.Configuration;
 18using MediaBrowser.Model.Entities;
 19using MediaBrowser.Model.IO;
 20using MediaBrowser.Model.Lyrics;
 21using MediaBrowser.Model.Providers;
 22using Microsoft.Extensions.Logging;
 23
 24namespace MediaBrowser.Providers.Lyric;
 25
 26/// <summary>
 27/// Lyric Manager.
 28/// </summary>
 29public class LyricManager : ILyricManager
 30{
 31    private readonly ILogger<LyricManager> _logger;
 32    private readonly IFileSystem _fileSystem;
 33    private readonly ILibraryMonitor _libraryMonitor;
 34    private readonly IMediaSourceManager _mediaSourceManager;
 35
 36    private readonly ILyricProvider[] _lyricProviders;
 37    private readonly ILyricParser[] _lyricParsers;
 38
 39    /// <summary>
 40    /// Initializes a new instance of the <see cref="LyricManager"/> class.
 41    /// </summary>
 42    /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
 43    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 44    /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
 45    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
 46    /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
 47    /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
 48    public LyricManager(
 49        ILogger<LyricManager> logger,
 50        IFileSystem fileSystem,
 51        ILibraryMonitor libraryMonitor,
 52        IMediaSourceManager mediaSourceManager,
 53        IEnumerable<ILyricProvider> lyricProviders,
 54        IEnumerable<ILyricParser> lyricParsers)
 55    {
 2256        _logger = logger;
 2257        _fileSystem = fileSystem;
 2258        _libraryMonitor = libraryMonitor;
 2259        _mediaSourceManager = mediaSourceManager;
 2260        _lyricProviders = lyricProviders
 2261            .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
 2262            .ToArray();
 2263        _lyricParsers = lyricParsers
 2264            .OrderBy(l => l.Priority)
 2265            .ToArray();
 2266    }
 67
 68    /// <inheritdoc />
 69    public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
 70
 71    /// <inheritdoc />
 72    public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken ca
 73    {
 074        ArgumentNullException.ThrowIfNull(audio);
 75
 076        var request = new LyricSearchRequest
 077        {
 078            MediaPath = audio.Path,
 079            SongName = audio.Name,
 080            AlbumName = audio.Album,
 081            ArtistNames = audio.GetAllArtists().ToList(),
 082            Duration = audio.RunTimeTicks,
 083            IsAutomated = isAutomated
 084        };
 85
 086        return SearchLyricsAsync(request, cancellationToken);
 87    }
 88
 89    /// <inheritdoc />
 90    public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken
 91    {
 92        ArgumentNullException.ThrowIfNull(request);
 93
 94        var providers = _lyricProviders
 95            .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
 96            .OrderBy(i =>
 97            {
 98                var index = request.LyricFetcherOrder.IndexOf(i.Name);
 99                return index == -1 ? int.MaxValue : index;
 100            })
 101            .ToArray();
 102
 103        // If not searching all, search one at a time until something is found
 104        if (!request.SearchAllProviders)
 105        {
 106            foreach (var provider in providers)
 107            {
 108                var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAw
 109                if (providerResult.Count > 0)
 110                {
 111                    return providerResult;
 112                }
 113            }
 114
 115            return [];
 116        }
 117
 118        var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellation
 119
 120        var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 121
 122        return results.SelectMany(i => i).ToArray();
 123    }
 124
 125    /// <inheritdoc />
 126    public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
 127    {
 0128        ArgumentNullException.ThrowIfNull(audio);
 0129        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 130
 0131        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 132
 0133        return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
 134    }
 135
 136    /// <inheritdoc />
 137    public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, Cancell
 138    {
 139        ArgumentNullException.ThrowIfNull(audio);
 140        ArgumentNullException.ThrowIfNull(libraryOptions);
 141        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 142
 143        var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
 144        if (provider is null)
 145        {
 146            return null;
 147        }
 148
 149        try
 150        {
 151            var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
 152            if (response is null)
 153            {
 154                _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
 155                return null;
 156            }
 157
 158            var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken)
 159            if (parsedLyrics is null)
 160            {
 161                return null;
 162            }
 163
 164            await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).ConfigureAwait(false);
 165            return parsedLyrics;
 166        }
 167        catch (RateLimitExceededException)
 168        {
 169            throw;
 170        }
 171        catch (Exception ex)
 172        {
 173            LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
 174            {
 175                Item = audio,
 176                Exception = ex,
 177                Provider = provider.Name
 178            });
 179
 180            throw;
 181        }
 182    }
 183
 184    /// <inheritdoc />
 185    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, string lyrics)
 186    {
 187        ArgumentNullException.ThrowIfNull(audio);
 188        ArgumentException.ThrowIfNullOrEmpty(format);
 189        ArgumentException.ThrowIfNullOrEmpty(lyrics);
 190
 191        var bytes = Encoding.UTF8.GetBytes(lyrics);
 192        using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
 193        return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
 194    }
 195
 196    /// <inheritdoc />
 197    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
 198    {
 199        ArgumentNullException.ThrowIfNull(audio);
 200        ArgumentException.ThrowIfNullOrEmpty(format);
 201        ArgumentNullException.ThrowIfNull(lyrics);
 202
 203        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 204
 205        var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
 206        if (parsed is null)
 207        {
 208            return null;
 209        }
 210
 211        await TrySaveLyric(audio, libraryOptions, format, lyrics).ConfigureAwait(false);
 212        return parsed;
 213    }
 214
 215    /// <inheritdoc />
 216    public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 217    {
 218        ArgumentException.ThrowIfNullOrEmpty(id);
 219
 220        var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 221        if (lyricResponse is null)
 222        {
 223            return null;
 224        }
 225
 226        return await InternalParseRemoteLyricsAsync(lyricResponse.Format, lyricResponse.Stream, cancellationToken).Confi
 227    }
 228
 229    /// <inheritdoc />
 230    public Task DeleteLyricsAsync(Audio audio)
 231    {
 0232        ArgumentNullException.ThrowIfNull(audio);
 0233        var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0234        {
 0235            ItemId = audio.Id,
 0236            Type = MediaStreamType.Lyric
 0237        });
 238
 0239        foreach (var stream in streams)
 240        {
 0241            var path = stream.Path;
 0242            _libraryMonitor.ReportFileSystemChangeBeginning(path);
 243
 244            try
 245            {
 0246                _fileSystem.DeleteFile(path);
 0247            }
 248            finally
 249            {
 0250                _libraryMonitor.ReportFileSystemChangeComplete(path, false);
 0251            }
 252        }
 253
 0254        return audio.RefreshMetadata(CancellationToken.None);
 255    }
 256
 257    /// <inheritdoc />
 258    public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
 259    {
 0260        if (item is not Audio)
 261        {
 0262            return [];
 263        }
 264
 0265        return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList()
 266    }
 267
 268    /// <inheritdoc />
 269    public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
 270    {
 271        ArgumentNullException.ThrowIfNull(audio);
 272
 273        var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
 274        foreach (var lyricStream in lyricStreams)
 275        {
 276            var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).Configur
 277
 278            var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
 279            foreach (var parser in _lyricParsers)
 280            {
 281                var parsedLyrics = parser.ParseLyrics(lyricFile);
 282                if (parsedLyrics is not null)
 283                {
 284                    return parsedLyrics;
 285                }
 286            }
 287        }
 288
 289        return null;
 290    }
 291
 292    private ILyricProvider? GetProvider(string providerId)
 293    {
 0294        var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringCompar
 0295        if (provider is null)
 296        {
 0297            _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
 298        }
 299
 0300        return provider;
 301    }
 302
 303    private string GetProviderId(string name)
 0304        => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 305
 306    private async Task<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken ca
 307    {
 308        lyricStream.Seek(0, SeekOrigin.Begin);
 309        using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
 310        var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 311        var lyricFile = new LyricFile($"lyric.{format}", lyrics);
 312        foreach (var parser in _lyricParsers)
 313        {
 314            var parsedLyrics = parser.ParseLyrics(lyricFile);
 315            if (parsedLyrics is not null)
 316            {
 317                return parsedLyrics;
 318            }
 319        }
 320
 321        return null;
 322    }
 323
 324    private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 325    {
 326        ArgumentException.ThrowIfNullOrWhiteSpace(id);
 327        var parts = id.Split('_', 2);
 328        var provider = GetProvider(parts[0]);
 329        if (provider is null)
 330        {
 331            return null;
 332        }
 333
 334        id = parts[^1];
 335
 336        return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 337    }
 338
 339    private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
 340        ILyricProvider provider,
 341        LyricSearchRequest request,
 342        CancellationToken cancellationToken)
 343    {
 344        try
 345        {
 346            var providerId = GetProviderId(provider.Name);
 347            var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
 348            var parsedResults = new List<RemoteLyricInfoDto>();
 349            foreach (var result in searchResults)
 350            {
 351                var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics.Format, result.Lyrics.Stream, canc
 352                if (parsedLyrics is null)
 353                {
 354                    continue;
 355                }
 356
 357                parsedLyrics.Metadata = result.Metadata;
 358                parsedResults.Add(new RemoteLyricInfoDto
 359                {
 360                    Id = $"{providerId}_{result.Id}",
 361                    ProviderName = result.ProviderName,
 362                    Lyrics = parsedLyrics
 363                });
 364            }
 365
 366            return parsedResults;
 367        }
 368        catch (Exception ex)
 369        {
 370            _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
 371            return [];
 372        }
 373    }
 374
 375    private async Task TrySaveLyric(
 376        Audio audio,
 377        LibraryOptions libraryOptions,
 378        string format,
 379        Stream lyricStream)
 380    {
 381        var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
 382
 383        var memoryStream = new MemoryStream();
 384        await using (memoryStream.ConfigureAwait(false))
 385        {
 386            await using (lyricStream.ConfigureAwait(false))
 387            {
 388                lyricStream.Seek(0, SeekOrigin.Begin);
 389                await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
 390                memoryStream.Seek(0, SeekOrigin.Begin);
 391            }
 392
 393            var savePaths = new List<string>();
 394            var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + format.ReplaceLineEndings(string.Emp
 395
 396            if (saveInMediaFolder)
 397            {
 398                var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
 399                // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
 400                if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
 401                {
 402                    savePaths.Add(mediaFolderPath);
 403                }
 404            }
 405
 406            var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
 407
 408            // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
 409            if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
 410            {
 411                savePaths.Add(internalPath);
 412            }
 413
 414            if (savePaths.Count > 0)
 415            {
 416                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
 417            }
 418            else
 419            {
 420                _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
 421            }
 422        }
 423    }
 424
 425    private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
 426    {
 427        List<Exception>? exs = null;
 428
 429        foreach (var savePath in savePaths)
 430        {
 431            _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
 432
 433            _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
 434
 435            try
 436            {
 437                Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path c
 438
 439                var fileOptions = AsyncFile.WriteOptions;
 440                fileOptions.Mode = FileMode.Create;
 441                fileOptions.PreallocationSize = stream.Length;
 442                var fs = new FileStream(savePath, fileOptions);
 443                await using (fs.ConfigureAwait(false))
 444                {
 445                    await stream.CopyToAsync(fs).ConfigureAwait(false);
 446                }
 447
 448                return;
 449            }
 450            catch (Exception ex)
 451            {
 452                (exs ??= []).Add(ex);
 453            }
 454            finally
 455            {
 456                _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
 457            }
 458
 459            stream.Position = 0;
 460        }
 461
 462        if (exs is not null)
 463        {
 464            throw new AggregateException(exs);
 465        }
 466    }
 467}