< 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: 38
Coverable lines: 49
Total lines: 468
Line coverage: 22.4%
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    {
 2156        _logger = logger;
 2157        _fileSystem = fileSystem;
 2158        _libraryMonitor = libraryMonitor;
 2159        _mediaSourceManager = mediaSourceManager;
 2160        _lyricProviders = lyricProviders
 2161            .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
 2162            .ToArray();
 2163        _lyricParsers = lyricParsers
 2164            .OrderBy(l => l.Priority)
 2165            .ToArray();
 2166    }
 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            AlbumArtistsNames = audio.AlbumArtists,
 082            ArtistNames = audio.Artists,
 083            Duration = audio.RunTimeTicks,
 084            IsAutomated = isAutomated
 085        };
 86
 087        return SearchLyricsAsync(request, cancellationToken);
 88    }
 89
 90    /// <inheritdoc />
 91    public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken
 92    {
 93        ArgumentNullException.ThrowIfNull(request);
 94
 95        var providers = _lyricProviders
 96            .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
 97            .OrderBy(i =>
 98            {
 99                var index = request.LyricFetcherOrder.IndexOf(i.Name);
 100                return index == -1 ? int.MaxValue : index;
 101            })
 102            .ToArray();
 103
 104        // If not searching all, search one at a time until something is found
 105        if (!request.SearchAllProviders)
 106        {
 107            foreach (var provider in providers)
 108            {
 109                var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAw
 110                if (providerResult.Count > 0)
 111                {
 112                    return providerResult;
 113                }
 114            }
 115
 116            return [];
 117        }
 118
 119        var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellation
 120
 121        var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 122
 123        return results.SelectMany(i => i).ToArray();
 124    }
 125
 126    /// <inheritdoc />
 127    public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
 128    {
 0129        ArgumentNullException.ThrowIfNull(audio);
 0130        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 131
 0132        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 133
 0134        return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
 135    }
 136
 137    /// <inheritdoc />
 138    public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, Cancell
 139    {
 140        ArgumentNullException.ThrowIfNull(audio);
 141        ArgumentNullException.ThrowIfNull(libraryOptions);
 142        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 143
 144        var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
 145        if (provider is null)
 146        {
 147            return null;
 148        }
 149
 150        try
 151        {
 152            var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
 153            if (response is null)
 154            {
 155                _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
 156                return null;
 157            }
 158
 159            var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken)
 160            if (parsedLyrics is null)
 161            {
 162                return null;
 163            }
 164
 165            await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).ConfigureAwait(false);
 166            return parsedLyrics;
 167        }
 168        catch (RateLimitExceededException)
 169        {
 170            throw;
 171        }
 172        catch (Exception ex)
 173        {
 174            LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
 175            {
 176                Item = audio,
 177                Exception = ex,
 178                Provider = provider.Name
 179            });
 180
 181            throw;
 182        }
 183    }
 184
 185    /// <inheritdoc />
 186    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, string lyrics)
 187    {
 188        ArgumentNullException.ThrowIfNull(audio);
 189        ArgumentException.ThrowIfNullOrEmpty(format);
 190        ArgumentException.ThrowIfNullOrEmpty(lyrics);
 191
 192        var bytes = Encoding.UTF8.GetBytes(lyrics);
 193        using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
 194        return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
 195    }
 196
 197    /// <inheritdoc />
 198    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
 199    {
 200        ArgumentNullException.ThrowIfNull(audio);
 201        ArgumentException.ThrowIfNullOrEmpty(format);
 202        ArgumentNullException.ThrowIfNull(lyrics);
 203
 204        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 205
 206        var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
 207        if (parsed is null)
 208        {
 209            return null;
 210        }
 211
 212        await TrySaveLyric(audio, libraryOptions, format, lyrics).ConfigureAwait(false);
 213        return parsed;
 214    }
 215
 216    /// <inheritdoc />
 217    public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 218    {
 219        ArgumentException.ThrowIfNullOrEmpty(id);
 220
 221        var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 222        if (lyricResponse is null)
 223        {
 224            return null;
 225        }
 226
 227        return await InternalParseRemoteLyricsAsync(lyricResponse.Format, lyricResponse.Stream, cancellationToken).Confi
 228    }
 229
 230    /// <inheritdoc />
 231    public Task DeleteLyricsAsync(Audio audio)
 232    {
 0233        ArgumentNullException.ThrowIfNull(audio);
 0234        var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0235        {
 0236            ItemId = audio.Id,
 0237            Type = MediaStreamType.Lyric
 0238        });
 239
 0240        foreach (var stream in streams)
 241        {
 0242            var path = stream.Path;
 0243            _libraryMonitor.ReportFileSystemChangeBeginning(path);
 244
 245            try
 246            {
 0247                _fileSystem.DeleteFile(path);
 0248            }
 249            finally
 250            {
 0251                _libraryMonitor.ReportFileSystemChangeComplete(path, false);
 0252            }
 253        }
 254
 0255        return audio.RefreshMetadata(CancellationToken.None);
 256    }
 257
 258    /// <inheritdoc />
 259    public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
 260    {
 0261        if (item is not Audio)
 262        {
 0263            return [];
 264        }
 265
 0266        return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList()
 267    }
 268
 269    /// <inheritdoc />
 270    public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
 271    {
 272        ArgumentNullException.ThrowIfNull(audio);
 273
 274        var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
 275        foreach (var lyricStream in lyricStreams)
 276        {
 277            var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).Configur
 278
 279            var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
 280            foreach (var parser in _lyricParsers)
 281            {
 282                var parsedLyrics = parser.ParseLyrics(lyricFile);
 283                if (parsedLyrics is not null)
 284                {
 285                    return parsedLyrics;
 286                }
 287            }
 288        }
 289
 290        return null;
 291    }
 292
 293    private ILyricProvider? GetProvider(string providerId)
 294    {
 0295        var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringCompar
 0296        if (provider is null)
 297        {
 0298            _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
 299        }
 300
 0301        return provider;
 302    }
 303
 304    private string GetProviderId(string name)
 0305        => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 306
 307    private async Task<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken ca
 308    {
 309        lyricStream.Seek(0, SeekOrigin.Begin);
 310        using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
 311        var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 312        var lyricFile = new LyricFile($"lyric.{format}", lyrics);
 313        foreach (var parser in _lyricParsers)
 314        {
 315            var parsedLyrics = parser.ParseLyrics(lyricFile);
 316            if (parsedLyrics is not null)
 317            {
 318                return parsedLyrics;
 319            }
 320        }
 321
 322        return null;
 323    }
 324
 325    private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 326    {
 327        ArgumentException.ThrowIfNullOrWhiteSpace(id);
 328        var parts = id.Split('_', 2);
 329        var provider = GetProvider(parts[0]);
 330        if (provider is null)
 331        {
 332            return null;
 333        }
 334
 335        id = parts[^1];
 336
 337        return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 338    }
 339
 340    private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
 341        ILyricProvider provider,
 342        LyricSearchRequest request,
 343        CancellationToken cancellationToken)
 344    {
 345        try
 346        {
 347            var providerId = GetProviderId(provider.Name);
 348            var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
 349            var parsedResults = new List<RemoteLyricInfoDto>();
 350            foreach (var result in searchResults)
 351            {
 352                var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics.Format, result.Lyrics.Stream, canc
 353                if (parsedLyrics is null)
 354                {
 355                    continue;
 356                }
 357
 358                parsedLyrics.Metadata = result.Metadata;
 359                parsedResults.Add(new RemoteLyricInfoDto
 360                {
 361                    Id = $"{providerId}_{result.Id}",
 362                    ProviderName = result.ProviderName,
 363                    Lyrics = parsedLyrics
 364                });
 365            }
 366
 367            return parsedResults;
 368        }
 369        catch (Exception ex)
 370        {
 371            _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
 372            return [];
 373        }
 374    }
 375
 376    private async Task TrySaveLyric(
 377        Audio audio,
 378        LibraryOptions libraryOptions,
 379        string format,
 380        Stream lyricStream)
 381    {
 382        var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
 383
 384        var memoryStream = new MemoryStream();
 385        await using (memoryStream.ConfigureAwait(false))
 386        {
 387            await using (lyricStream.ConfigureAwait(false))
 388            {
 389                lyricStream.Seek(0, SeekOrigin.Begin);
 390                await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
 391                memoryStream.Seek(0, SeekOrigin.Begin);
 392            }
 393
 394            var savePaths = new List<string>();
 395            var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + format.ReplaceLineEndings(string.Emp
 396
 397            if (saveInMediaFolder)
 398            {
 399                var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
 400                // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
 401                if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
 402                {
 403                    savePaths.Add(mediaFolderPath);
 404                }
 405            }
 406
 407            var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
 408
 409            // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
 410            if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
 411            {
 412                savePaths.Add(internalPath);
 413            }
 414
 415            if (savePaths.Count > 0)
 416            {
 417                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
 418            }
 419            else
 420            {
 421                _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
 422            }
 423        }
 424    }
 425
 426    private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
 427    {
 428        List<Exception>? exs = null;
 429
 430        foreach (var savePath in savePaths)
 431        {
 432            _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
 433
 434            _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
 435
 436            try
 437            {
 438                Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path c
 439
 440                var fileOptions = AsyncFile.WriteOptions;
 441                fileOptions.Mode = FileMode.Create;
 442                fileOptions.PreallocationSize = stream.Length;
 443                var fs = new FileStream(savePath, fileOptions);
 444                await using (fs.ConfigureAwait(false))
 445                {
 446                    await stream.CopyToAsync(fs).ConfigureAwait(false);
 447                }
 448
 449                return;
 450            }
 451            catch (Exception ex)
 452            {
 453                (exs ??= []).Add(ex);
 454            }
 455            finally
 456            {
 457                _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
 458            }
 459
 460            stream.Position = 0;
 461        }
 462
 463        if (exs is not null)
 464        {
 465            throw new AggregateException(exs);
 466        }
 467    }
 468}